From 68404a067f234df321c711d10fed5a2462036c4a Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Tue, 7 Jul 2026 22:15:12 +0300 Subject: [PATCH 001/140] chore: stop tracking AI-assistant config and internal docs Remove .claude/, CLAUDE.md, .github/copilot-instructions.md and docs/ from version control (kept locally, gitignored) so they are not published to GitHub. --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index c5bd634..067247c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,13 @@ /bin/hkm-config +# ── AI ASSISTANT / INTERNAL DOCS (kept local, NOT shipped to GitHub) ── +/.claude/ +/CLAUDE.md +/.github/copilot-instructions.md +/docs/ + + # ── COMPOSER ────────────────────────────────────────────────── /vendor/ #(Uncomment if you want to ignore lock file for the library) From fe07980cdac714959ac4ed6395714ff44c4386e7 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Tue, 7 Jul 2026 23:23:36 +0300 Subject: [PATCH 002/140] feat(cli): show Sentinel banner on bare hkm and hkm help Previously the ASCII banner only appeared on 'hkm version'. It now headers the default help output too. --- CHANGELOG.md | 6 ++++++ tools/src/main.zig | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb96bb9..27c56aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.1] - 2026-07-07 + +### Changed +- The Sentinel ASCII banner + version now shows as the header of `hkm` and + `hkm help` (previously only on `hkm version`). + ## [1.0.0] - 2026-07-07 First native release — the framework ships as OS-native bundles for Linux, diff --git a/tools/src/main.zig b/tools/src/main.zig index afaaedc..d7b9993 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -13,7 +13,7 @@ const banner = @import("lib/banner.zig"); const prompt = @import("lib/prompt.zig"); fn printHelp() void { - prompt.intro("hkm launcher"); + banner.print(); prompt.section("Usage"); prompt.item("hkm new [opts]", "scaffold a new PhpServicePlatform project"); From 10c8f37db9aaf76c5b443c37ae9902a23c383b4a Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Tue, 7 Jul 2026 23:53:37 +0300 Subject: [PATCH 003/140] feat(upgrade): auto-download and install updates per OS hkm upgrade now detects the OS, downloads the matching release artifact, and installs it (Linux apt / macOS install.sh / Windows install.bat), instead of only printing manual instructions. --- CHANGELOG.md | 9 ++++ tools/src/commands/upgrade.zig | 93 +++++++++++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c56aa..5b96fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.2] - 2026-07-07 + +### Changed +- `hkm upgrade` now performs the update automatically for packaged installs: + it detects the OS, downloads the matching release artifact (`.deb` / `.tar.gz` + / `.zip`), and installs it (Linux: `apt`; macOS: extract + `install.sh`; + Windows: downloads and points at `install.bat`). Previously it only printed + manual instructions. + ## [1.0.1] - 2026-07-07 ### Changed diff --git a/tools/src/commands/upgrade.zig b/tools/src/commands/upgrade.zig index 060e7b1..5c2199a 100644 --- a/tools/src/commands/upgrade.zig +++ b/tools/src/commands/upgrade.zig @@ -154,11 +154,92 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c return 0; } - // Packaged install (.deb/.app/zip): guide the reinstall from the release. - prompt.section("Update a packaged install"); - prompt.item("download", try std.fmt.allocPrint(allocator, "https://github.com/{s}/releases/latest", .{banner.repo()})); - prompt.item("Linux (.deb)", "sudo apt install ./hkm-kernel__amd64.deb"); - prompt.item("macOS/Windows", "extract the archive, then run install.sh / install.bat"); - prompt.item("note", "packaged installs are replaced by reinstalling the newer artifact"); + // Packaged install: detect OS, download the matching artifact, install it. + return performPackagedUpgrade(allocator, io, env, latest); +} + +/// Download the release artifact for THIS OS and install it. The binary is built +/// per-OS, so builtin.os.tag / cpu.arch are comptime — only this platform's path +/// is compiled in. +fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, latest: []const u8) !u8 { + const os = @import("builtin").os.tag; + const arch = @import("builtin").cpu.arch; + const ver = if (latest.len > 0 and (latest[0] == 'v' or latest[0] == 'V')) latest[1..] else latest; // "1.0.1" + + const asset: []const u8 = switch (os) { + .linux => try std.fmt.allocPrint(allocator, "hkm-kernel_{s}_amd64.deb", .{ver}), + .macos => try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-macos-universal.tar.gz", .{ver}), + .windows => try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-windows-x86_64.zip", .{ver}), + else => return errUnsupported(), + }; + if (os == .linux and arch != .x86_64) { + prompt.err("only an amd64 .deb is published; your architecture has no prebuilt package."); + return 1; + } + + const url = try std.fmt.allocPrint( + allocator, + "https://github.com/{s}/releases/download/{s}/{s}", + .{ banner.repo(), latest, asset }, + ); + const tmp = try std.fs.path.join(allocator, &.{ "/tmp", asset }); + + prompt.section("Downloading update"); + prompt.item("asset", asset); + prompt.item("from", url); + if (!download(io, env, url, tmp)) { + prompt.err("download failed — check your connection and try again."); + return 1; + } + + prompt.section("Installing"); + switch (os) { + .linux => { + // apt handles the local .deb + its dependencies; needs root. + var argv = [_][]const u8{ "sudo", "apt-get", "install", "-y", tmp }; + const code = run_cmd.spawnWait(io, env, &argv) catch 1; + if (code != 0) { + // Fallback: dpkg then fix deps. + var dpkg = [_][]const u8{ "sudo", "dpkg", "-i", tmp }; + _ = run_cmd.spawnWait(io, env, &dpkg) catch {}; + var fix = [_][]const u8{ "sudo", "apt-get", "-f", "install", "-y" }; + _ = run_cmd.spawnWait(io, env, &fix) catch {}; + } + }, + .macos => { + // Replace the kernel resources in place, then re-resolve composer. + const root = kernelRoot(allocator, io, env) orelse "/Applications/HKM.app/Contents/Resources/opt/hkm-kernel"; + const app_root = std.fs.path.dirname(std.fs.path.dirname(std.fs.path.dirname(root) orelse root) orelse root) orelse root; + var untar = [_][]const u8{ "tar", "-xzf", tmp, "-C", app_root, "--strip-components=0" }; + _ = run_cmd.spawnWait(io, env, &untar) catch {}; + const installer = try std.fs.path.join(allocator, &.{ root, "install.sh" }); + if (util.fileExists(io, installer)) { + var sh = [_][]const u8{ "sh", installer }; + _ = run_cmd.spawnWait(io, env, &sh) catch {}; + } + }, + .windows => { + prompt.warn("downloaded — extract the zip and run install.bat to finish (Windows self-replace is unsafe while running)."); + prompt.item("saved to", tmp); + return 0; + }, + else => return errUnsupported(), + } + + prompt.blank(); + prompt.ok("updated. Verify with: hkm doctor"); return 0; } + +fn errUnsupported() u8 { + prompt.err("automatic upgrade is not supported on this platform — download from the releases page."); + return 1; +} + +/// Download url → dest with curl (fallback wget), stdio inherited for a progress bar. +fn download(io: Io, env: *EnvMap, url: []const u8, dest: []const u8) bool { + var curl = [_][]const u8{ "curl", "-fSL", "--progress-bar", "-o", dest, url }; + if ((run_cmd.spawnWait(io, env, &curl) catch 1) == 0) return true; + var wget = [_][]const u8{ "wget", "-O", dest, url }; + return (run_cmd.spawnWait(io, env, &wget) catch 1) == 0; +} From bacb6e8856c4b54fe4193dfbcb26ed414f11f46b Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 00:12:49 +0300 Subject: [PATCH 004/140] fix(cli): self-locate installed kernel + real hkm-config + load config.env - run/registry now resolve the kernel relative to the launcher (installed /opt/hkm-kernel or dev repo), fixing 'Kernel registry not found' on packaged installs and stopping use of a dev kernel found via PWD. - hkm-config checks the kernel + writes/repairs HKM_KERNEL_HOME. - launcher loads ~/.config/hkm/config.env at startup (real env wins). No version change. --- CHANGELOG.md | 12 +++ tools/src/config.zig | 170 +++++++++++++++++++++++------------ tools/src/lib/kernel.zig | 38 ++++++++ tools/src/lib/registry.zig | 8 ++ tools/src/lib/services.zig | 19 ++-- tools/src/lib/userconfig.zig | 92 +++++++++++++++++++ tools/src/main.zig | 5 ++ 7 files changed, 277 insertions(+), 67 deletions(-) create mode 100644 tools/src/lib/userconfig.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b96fde..f728232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `hkm run` / `hkm run --pick` / the registry now **self-locate the installed + kernel** (`/opt/hkm-kernel`, or the dir relative to the launcher) instead of + only using env vars or a dev tree found by walking up from the CWD. Fixes + "Kernel registry not found" on packaged installs and stops an installed + launcher from silently using a development kernel. +- `hkm-config` is now a real config checker: it resolves the kernel, verifies + `vendor/autoload.php` + the projects registry, and writes/repairs + `HKM_KERNEL_HOME` in `~/.config/hkm/config.env`. +- The launcher now **loads `~/.config/hkm/config.env`** at startup (real + environment variables still win), so `hkm-config` settings actually apply. + ## [1.0.2] - 2026-07-07 ### Changed diff --git a/tools/src/config.zig b/tools/src/config.zig index 5838176..45ba951 100644 --- a/tools/src/config.zig +++ b/tools/src/config.zig @@ -1,80 +1,134 @@ -const std = @import("std"); - -fn printHelp() void { - std.debug.print( - "hkm-config\\n" ++ - "Usage:\\n" ++ - " hkm-config print\\n" ++ - " hkm-config set-kernel-home \\n" ++ - " hkm-config set-autoload \\n", - .{}, - ); -} +//! `hkm-config` — inspect and repair the launcher's persistent configuration +//! (`~/.config/hkm/config.env`, loaded by `hkm` at startup). +//! +//! hkm-config # check config; auto-configure if incomplete +//! hkm-config check # same as no-args +//! hkm-config print # show the config file path + contents +//! hkm-config set-kernel-home

# pin HKM_KERNEL_HOME +//! hkm-config set-autoload

# pin HKM_GLOBAL_AUTOLOAD (vendor/autoload.php) +//! +//! "check" resolves the kernel (env → relative to this binary → /opt/hkm-kernel) +//! and, if the config file is missing or stale, writes HKM_KERNEL_HOME for you. -fn configPath(allocator: std.mem.Allocator, env_map: *std.process.Environ.Map) ![]const u8 { - if (env_map.get("HOME")) |home| { - return try std.fmt.allocPrint(allocator, "{s}/.config/hkm/config.env", .{home}); - } - return error.MissingHome; -} +const std = @import("std"); +const kernel = @import("lib/kernel.zig"); +const userconfig = @import("lib/userconfig.zig"); +const prompt = @import("lib/prompt.zig"); +const util = @import("lib/util.zig"); -fn ensureConfigDir(path: []const u8) !void { - const dir = std.fs.path.dirname(path) orelse return; - const io = std.Io.Threaded.global_single_threaded.io(); - try std.Io.Dir.createDirPath(std.Io.Dir.cwd(), io, dir); -} +const Io = std.Io; +const EnvMap = std.process.Environ.Map; pub fn main(init: std.process.Init.Minimal) !void { - var arena_allocator: std.heap.ArenaAllocator = .init(std.heap.page_allocator); - defer arena_allocator.deinit(); - const allocator = arena_allocator.allocator(); - var env_map = try init.environ.createMap(allocator); - defer env_map.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + var threaded: std.Io.Threaded = .init(std.heap.page_allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + var env = try init.environ.createMap(allocator); + defer env.deinit(); + + // Make already-saved config visible to resolution below. + userconfig.load(allocator, io, &env); const args = try init.args.toSlice(allocator); + const action = if (args.len >= 2) args[1] else "check"; - if (args.len < 2) { - printHelp(); + if (std.mem.eql(u8, action, "print")) { + const p = (try userconfig.path(allocator, &env)) orelse { + prompt.err("cannot resolve config path (no HOME)."); + std.process.exit(1); + }; + prompt.section("Config file"); + prompt.item("path", p); + if (std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(64 * 1024))) |c| { + prompt.blank(); + std.debug.print("{s}\n", .{c}); + } else |_| prompt.muted("(file does not exist yet — run `hkm-config` to create it)"); return; } - const action = args[1]; - const cfg = try configPath(allocator, &env_map); - - if (std.mem.eql(u8, action, "print")) { - std.debug.print("{s}\\n", .{cfg}); + if (std.mem.eql(u8, action, "set-kernel-home")) { + if (args.len < 3) return usage(); + try userconfig.set(allocator, io, &env, "HKM_KERNEL_HOME", args[2]); + prompt.ok("HKM_KERNEL_HOME saved."); return; } - - if (args.len < 3) { - printHelp(); + if (std.mem.eql(u8, action, "set-autoload")) { + if (args.len < 3) return usage(); + try userconfig.set(allocator, io, &env, "HKM_GLOBAL_AUTOLOAD", args[2]); + prompt.ok("HKM_GLOBAL_AUTOLOAD saved."); return; } + if (std.mem.eql(u8, action, "check") or std.mem.eql(u8, action, "configure")) { + std.process.exit(try runCheck(allocator, io, &env)); + } - const value = args[2]; - try ensureConfigDir(cfg); + usage(); +} - const io = std.Io.Threaded.global_single_threaded.io(); - var file = try std.Io.Dir.createFile(std.Io.Dir.cwd(), io, cfg, .{ .truncate = true }); - defer file.close(io); - var buffer: [512]u8 = undefined; - var writer = file.writer(io, &buffer); +fn usage() void { + prompt.section("hkm-config"); + prompt.item("hkm-config", "check config; auto-configure if incomplete"); + prompt.item("hkm-config print", "show the config file path + contents"); + prompt.item("hkm-config set-kernel-home

", "pin the kernel root"); + prompt.item("hkm-config set-autoload

", "pin vendor/autoload.php"); +} - if (std.mem.eql(u8, action, "set-kernel-home")) { - try writer.interface.writeAll("HKM_KERNEL_HOME="); - try writer.interface.writeAll(value); - try writer.interface.writeAll("\n"); - try writer.flush(); - return; +fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { + prompt.intro("hkm-config"); + + const cfg = (try userconfig.path(allocator, env)) orelse { + prompt.err("no HOME — cannot locate ~/.config/hkm/config.env"); + return 1; + }; + prompt.section("Configuration"); + prompt.item("config file", cfg); + prompt.item("exists", if (util.fileExists(io, cfg)) "yes" else "no (will create)"); + + // 1. Locate the kernel. + const home = (try kernel.resolveHome(allocator, io, env)) orelse { + prompt.blank(); + prompt.err("no kernel found."); + prompt.item("fix", "install the hkm-kernel package, or: hkm-config set-kernel-home "); + return 1; + }; + prompt.item("kernel home", home); + + // 2. Check kernel pieces. + const autoload = try std.fs.path.join(allocator, &.{ home, "vendor", "autoload.php" }); + const projects = try std.fs.path.join(allocator, &.{ home, "projects", "projects.json" }); + const have_vendor = util.fileExists(io, autoload); + const have_registry = util.fileExists(io, projects); + prompt.item("vendor/autoload.php", if (have_vendor) "present" else "MISSING"); + prompt.item("projects registry", if (have_registry) "present" else "absent (no projects registered yet)"); + + // 3. Ensure HKM_KERNEL_HOME is persisted and current. + const saved = try userconfig.get(allocator, io, env, "HKM_KERNEL_HOME"); + var wrote = false; + if (saved == null or !std.mem.eql(u8, saved.?, home)) { + try userconfig.set(allocator, io, env, "HKM_KERNEL_HOME", home); + wrote = true; } - if (std.mem.eql(u8, action, "set-autoload")) { - try writer.interface.writeAll("HKM_GLOBAL_AUTOLOAD="); - try writer.interface.writeAll(value); - try writer.interface.writeAll("\n"); - try writer.flush(); - return; + prompt.blank(); + if (!have_vendor) { + prompt.warn("kernel dependencies are not installed."); + const installer = try std.fs.path.join(allocator, &.{ home, "install.sh" }); + if (util.fileExists(io, installer)) { + prompt.item("run", try std.fmt.allocPrint(allocator, "sh {s}", .{installer})); + } else { + prompt.item("run", try std.fmt.allocPrint(allocator, "cd {s} && composer install --no-dev", .{home})); + } + return 1; } - printHelp(); + if (wrote) { + prompt.ok("configuration written — HKM_KERNEL_HOME pinned."); + } else { + prompt.ok("configuration is complete."); + } + prompt.muted("verify the runtime with: hkm doctor"); + return 0; } diff --git a/tools/src/lib/kernel.zig b/tools/src/lib/kernel.zig index df5ffbd..2a38e70 100644 --- a/tools/src/lib/kernel.zig +++ b/tools/src/lib/kernel.zig @@ -65,6 +65,44 @@ pub fn findCliPath(allocator: std.mem.Allocator, io: Io, env: *EnvMap) ![]const return (try resolve(allocator, io, env)).path; } +/// A directory is a kernel root if it holds composer.json (true for both the +/// dev monorepo and an installed /opt/hkm-kernel). +fn isKernelRoot(io: Io, dir: []const u8) bool { + var buf: [4096]u8 = undefined; + const marker = std.fmt.bufPrint(&buf, "{s}/composer.json", .{dir}) catch return false; + return util.fileExists(io, marker); +} + +/// Resolve the kernel ROOT directory (the folder holding composer.json, vendor/, +/// projects/). Used by `run`, the registry, and `hkm-config`. Order: +/// 1. HKM_KERNEL_HOME +/// 2. self-located relative to THIS executable (installed .deb/.app/zip, or the +/// dev monorepo when running repo/bin/hkm) +/// 3. /opt/hkm-kernel default +/// Returns null when no kernel can be found. +pub fn resolveHome(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const u8 { + if (env.get("HKM_KERNEL_HOME")) |h| { + if (h.len > 0) return util.trimSlash(h); + } + if (std.process.executableDirPathAlloc(io, allocator)) |dir| { + // parent = the dir ABOVE the executable's dir (normalized, no ".."). + const parent = std.fs.path.dirname(dir) orelse dir; + const rels = [_][]const []const u8{ + &.{ parent, "Resources", "opt", "hkm-kernel" }, // macOS .app (MacOS→Contents) + &.{ dir, "hkm-kernel" }, // windows/portable zip + &.{ parent, "opt", "hkm-kernel" }, // portable + &.{ parent, "lib", "hkm-kernel" }, + &.{parent}, // dev monorepo: repo/bin/hkm → repo root + }; + for (rels) |parts| { + const cand = try std.fs.path.join(allocator, parts); + if (isKernelRoot(io, cand)) return cand; + } + } else |_| {} + if (isKernelRoot(io, "/opt/hkm-kernel")) return "/opt/hkm-kernel"; + return null; +} + pub fn sourceLabel(s: Source) []const u8 { return switch (s) { .cli_path_env => "HKM_CLI_PATH override", diff --git a/tools/src/lib/registry.zig b/tools/src/lib/registry.zig index 7eb8ddc..d7db29e 100644 --- a/tools/src/lib/registry.zig +++ b/tools/src/lib/registry.zig @@ -7,6 +7,7 @@ const std = @import("std"); const util = @import("util.zig"); +const kernel = @import("kernel.zig"); const Dir = std.Io.Dir; const Io = std.Io; @@ -34,6 +35,13 @@ pub fn resolvePath(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const if (env.get("HKM_KERNEL_HOME")) |h| { if (h.len > 0) return try std.fmt.allocPrint(allocator, "{s}/projects/projects.json", .{trimSlash(h)}); } + // Self-locate the kernel relative to THIS executable (installed .deb/.app/zip + // or the dev monorepo). This is what makes `hkm run --pick` work on a packaged + // install with no env vars set — the registry lives at /projects/. + if (try kernel.resolveHome(allocator, io, env)) |home| { + const p = try std.fmt.allocPrint(allocator, "{s}/projects/projects.json", .{home}); + if (Dir.cwd().access(io, p, .{})) |_| return p else |_| {} + } if (env.get("PWD")) |pwd| { if (pwd.len > 0) return findUpwards(allocator, io, pwd); } diff --git a/tools/src/lib/services.zig b/tools/src/lib/services.zig index 673ed29..cc496eb 100644 --- a/tools/src/lib/services.zig +++ b/tools/src/lib/services.zig @@ -6,6 +6,7 @@ const std = @import("std"); const registry = @import("registry.zig"); +const kernel = @import("kernel.zig"); const util = @import("util.zig"); const Dir = std.Io.Dir; @@ -48,11 +49,12 @@ pub fn resolveAutoload(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]c if (env.get("HKM_GLOBAL_AUTOLOAD")) |v| { if (v.len > 0) return v; } - if (env.get("HKM_KERNEL_HOME")) |h| { - if (h.len > 0) { - const p = try std.fmt.allocPrint(allocator, "{s}/vendor/autoload.php", .{util.trimSlash(h)}); - if (util.fileExists(io, p)) return p; - } + // Self-locate the kernel (HKM_KERNEL_HOME, then relative to this executable, + // then /opt/hkm-kernel) and use its vendor/autoload.php. This makes an + // installed launcher use the INSTALLED kernel — not a dev tree found via PWD. + if (try kernel.resolveHome(allocator, io, env)) |home| { + const p = try std.fmt.allocPrint(allocator, "{s}/vendor/autoload.php", .{home}); + if (util.fileExists(io, p)) return p; } if (try registry.resolvePath(allocator, io, env)) |jsonPath| { if (util.parentOf(util.parentOf(jsonPath))) |kernel_root| { @@ -71,15 +73,14 @@ pub fn resolveAutoload(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]c /// apply. Used to export HKM_KERNEL_HOME to child processes so a served app's /// runtime `getenv('HKM_KERNEL_HOME')` resolves. pub fn resolveKernelHome(allocator: std.mem.Allocator, io: Io, env: *EnvMap, autoload: ?[]const u8) !?[]const u8 { - if (env.get("HKM_KERNEL_HOME")) |h| { - if (h.len > 0) return util.trimSlash(h); - } - // /vendor/autoload.php → + // /vendor/autoload.php → (keeps home consistent with the autoload + // we already resolved). if (autoload) |a| { if (std.mem.endsWith(u8, a, "/vendor/autoload.php")) { if (util.parentOf(util.parentOf(a))) |home| return home; } } + if (try kernel.resolveHome(allocator, io, env)) |home| return home; if (try registry.resolvePath(allocator, io, env)) |jsonPath| { if (util.parentOf(util.parentOf(jsonPath))) |kernel_root| { if (util.dirExists(Dir.cwd(), io, kernel_root)) return kernel_root; diff --git a/tools/src/lib/userconfig.zig b/tools/src/lib/userconfig.zig new file mode 100644 index 0000000..6bd853f --- /dev/null +++ b/tools/src/lib/userconfig.zig @@ -0,0 +1,92 @@ +//! Persistent user config for the hkm launcher: `~/.config/hkm/config.env`. +//! +//! A tiny KEY=VALUE file (e.g. HKM_KERNEL_HOME=/opt/hkm-kernel). The launcher +//! LOADS it into the environment at startup, so values written by `hkm-config` +//! actually take effect for `hkm run`, the registry, and the PHP passthrough. +//! REAL process-environment values always win (a shell export overrides the file). + +const std = @import("std"); +const Io = std.Io; +const EnvMap = std.process.Environ.Map; +const Dir = std.Io.Dir; + +/// Absolute path to the config file, honouring XDG_CONFIG_HOME then HOME. +pub fn path(allocator: std.mem.Allocator, env: *EnvMap) !?[]const u8 { + if (env.get("XDG_CONFIG_HOME")) |x| { + if (x.len > 0) return try std.fmt.allocPrint(allocator, "{s}/hkm/config.env", .{x}); + } + if (env.get("HOME")) |home| { + if (home.len > 0) return try std.fmt.allocPrint(allocator, "{s}/.config/hkm/config.env", .{home}); + } + return null; +} + +/// Load KEY=VALUE lines into `env`, WITHOUT overriding keys already set in the +/// real environment. Silently no-ops if the file is absent. Best-effort. +pub fn load(allocator: std.mem.Allocator, io: Io, env: *EnvMap) void { + const cfg = (path(allocator, env) catch return) orelse return; + const content = Dir.cwd().readFileAlloc(io, cfg, allocator, .limited(64 * 1024)) catch return; + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0 or line[0] == '#') continue; + const eq = std.mem.indexOfScalar(u8, line, '=') orelse continue; + const key = std.mem.trim(u8, line[0..eq], " \t"); + const val = std.mem.trim(u8, line[eq + 1 ..], " \t"); + if (key.len == 0) continue; + // Process env wins — only fill in what isn't already set. + if (env.get(key) != null) continue; + env.put(key, val) catch continue; + } +} + +/// Read a single key from the config file (not the environment). Null if absent. +pub fn get(allocator: std.mem.Allocator, io: Io, env: *EnvMap, key: []const u8) !?[]const u8 { + const cfg = (try path(allocator, env)) orelse return null; + const content = Dir.cwd().readFileAlloc(io, cfg, allocator, .limited(64 * 1024)) catch return null; + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + const eq = std.mem.indexOfScalar(u8, line, '=') orelse continue; + if (std.mem.eql(u8, std.mem.trim(u8, line[0..eq], " \t"), key)) { + return try allocator.dupe(u8, std.mem.trim(u8, line[eq + 1 ..], " \t")); + } + } + return null; +} + +/// Set (insert or replace) KEY=VALUE in the config file, preserving other keys. +pub fn set(allocator: std.mem.Allocator, io: Io, env: *EnvMap, key: []const u8, value: []const u8) !void { + const cfg = (try path(allocator, env)) orelse return error.MissingHome; + if (std.fs.path.dirname(cfg)) |dir| try Dir.cwd().createDirPath(io, dir); + + var out: std.ArrayList(u8) = .empty; + var replaced = false; + + if (Dir.cwd().readFileAlloc(io, cfg, allocator, .limited(64 * 1024))) |content| { + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r"); + if (line.len == 0) continue; + const eq = std.mem.indexOfScalar(u8, line, '='); + if (eq != null and std.mem.eql(u8, std.mem.trim(u8, line[0..eq.?], " \t"), key)) { + try out.appendSlice(allocator, key); + try out.append(allocator, '='); + try out.appendSlice(allocator, value); + try out.append(allocator, '\n'); + replaced = true; + } else { + try out.appendSlice(allocator, line); + try out.append(allocator, '\n'); + } + } + } else |_| {} + + if (!replaced) { + try out.appendSlice(allocator, key); + try out.append(allocator, '='); + try out.appendSlice(allocator, value); + try out.append(allocator, '\n'); + } + try Dir.cwd().writeFile(io, .{ .sub_path = cfg, .data = out.items }); +} diff --git a/tools/src/main.zig b/tools/src/main.zig index d7b9993..ab4c994 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -9,6 +9,7 @@ const cli_cmd = @import("commands/cli.zig"); const doctor_cmd = @import("commands/doctor.zig"); const upgrade_cmd = @import("commands/upgrade.zig"); const kernel = @import("lib/kernel.zig"); +const userconfig = @import("lib/userconfig.zig"); const banner = @import("lib/banner.zig"); const prompt = @import("lib/prompt.zig"); @@ -69,6 +70,10 @@ pub fn main(init: std.process.Init.Minimal) !void { var env_map = try init.environ.createMap(allocator); defer env_map.deinit(); + // Load persistent config (~/.config/hkm/config.env) so values written by + // `hkm-config` take effect. Real environment variables always win. + userconfig.load(allocator, io, &env_map); + const args = try init.args.toSlice(allocator); if (args.len <= 1) { From f36760336bce758b663806e48f9b89b06adc66f2 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 00:25:04 +0300 Subject: [PATCH 005/140] fix(templates): move templates out of tools/ so they ship in the kernel Scaffolding templates moved tools/src/templates -> top-level templates/. tools/ is not bundled, so hkm new / hkm ui init could not find templates on a packaged install. bundle.sh now ships templates/ (exempt from the docs/ tools strip); services resolves /templates via self-location. No version change. --- CHANGELOG.md | 5 +++++ {tools/src/templates => templates}/README.md | 0 .../templates => templates}/app/bootstrap/app.php | 0 .../app/bootstrap/kernel-autoload.php | 0 {tools/src/templates => templates}/app/cli/run.php | 0 .../templates => templates}/app/public/index.php | 0 .../templates => templates}/app/swoole/index.php | 0 .../src/templates => templates}/app/worker/run.php | 0 {tools/src/templates => templates}/composer.json | 0 .../config/environments/local.php | 0 .../config/environments/production.php | 0 .../config/environments/staging.php | 0 .../config/environments/testing.php | 0 .../templates => templates}/config/let-migrate.php | 0 .../src/templates => templates}/config/storage.php | 0 {tools/src/templates => templates}/env.example | 0 .../templates => templates}/frontend/.gitignore | 0 .../src/templates => templates}/frontend/README.md | 0 .../frontend/components.json | 0 .../frontend/docs/HOW_IT_WORKS.md | 0 .../templates => templates}/frontend/index.html | 0 .../templates => templates}/frontend/package.json | 0 .../frontend/src/shared/hooks/use-toast.ts | 0 .../frontend/src/shared/lib/utils.ts | 0 .../frontend/src/shared/providers/theme.tsx | 0 .../frontend/src/shared/styles/theme.css | 0 .../frontend/src/shared/ui/accordion.tsx | 0 .../frontend/src/shared/ui/alert-dialog.tsx | 0 .../frontend/src/shared/ui/alert.tsx | 0 .../frontend/src/shared/ui/aspect-ratio.tsx | 0 .../frontend/src/shared/ui/avatar.tsx | 0 .../frontend/src/shared/ui/badge.tsx | 0 .../frontend/src/shared/ui/breadcrumb.tsx | 0 .../frontend/src/shared/ui/button.tsx | 0 .../frontend/src/shared/ui/calendar.tsx | 0 .../frontend/src/shared/ui/card.tsx | 0 .../frontend/src/shared/ui/carousel.tsx | 0 .../frontend/src/shared/ui/chart.tsx | 0 .../frontend/src/shared/ui/checkbox.tsx | 0 .../frontend/src/shared/ui/collapsible.tsx | 0 .../frontend/src/shared/ui/command.tsx | 0 .../frontend/src/shared/ui/context-menu.tsx | 0 .../frontend/src/shared/ui/dialog.tsx | 0 .../frontend/src/shared/ui/drawer.tsx | 0 .../frontend/src/shared/ui/dropdown-menu.tsx | 0 .../frontend/src/shared/ui/form-standalone.tsx | 0 .../frontend/src/shared/ui/form.tsx | 0 .../frontend/src/shared/ui/hover-card.tsx | 0 .../frontend/src/shared/ui/input-otp.tsx | 0 .../frontend/src/shared/ui/input.tsx | 0 .../frontend/src/shared/ui/label.tsx | 0 .../frontend/src/shared/ui/menubar.tsx | 0 .../frontend/src/shared/ui/navigation-menu.tsx | 0 .../frontend/src/shared/ui/page-transition.tsx | 0 .../frontend/src/shared/ui/pagination.tsx | 0 .../frontend/src/shared/ui/popover.tsx | 0 .../frontend/src/shared/ui/progress.tsx | 0 .../frontend/src/shared/ui/radio-group.tsx | 0 .../frontend/src/shared/ui/resizable.tsx | 0 .../frontend/src/shared/ui/scroll-area.tsx | 0 .../frontend/src/shared/ui/select.tsx | 0 .../frontend/src/shared/ui/separator.tsx | 0 .../frontend/src/shared/ui/sheet.tsx | 0 .../frontend/src/shared/ui/skeleton.tsx | 0 .../frontend/src/shared/ui/slider.tsx | 0 .../frontend/src/shared/ui/sonner.tsx | 0 .../frontend/src/shared/ui/switch.tsx | 0 .../frontend/src/shared/ui/table.tsx | 0 .../frontend/src/shared/ui/tabs.tsx | 0 .../frontend/src/shared/ui/textarea.tsx | 0 .../frontend/src/shared/ui/toast.tsx | 0 .../frontend/src/shared/ui/toaster.tsx | 0 .../frontend/src/shared/ui/toggle-group.tsx | 0 .../frontend/src/shared/ui/toggle.tsx | 0 .../frontend/src/shared/ui/tooltip.tsx | 0 .../src/surfaces/admin/Pages/Dashboard.tsx | 0 .../frontend/src/surfaces/admin/Pages/Login.tsx | 0 .../src/surfaces/admin/Pages/Users/Index.tsx | 0 .../frontend/src/surfaces/admin/index.tsx | 0 .../frontend/src/surfaces/admin/styles/index.css | 0 .../frontend/src/surfaces/admin/surface.json | 0 .../frontend/src/surfaces/project/Pages/About.tsx | 0 .../frontend/src/surfaces/project/Pages/Home.tsx | 0 .../frontend/src/surfaces/project/index.tsx | 0 .../frontend/src/surfaces/project/styles/index.css | 0 .../frontend/src/surfaces/project/surface.json | 0 .../templates => templates}/frontend/tsconfig.json | 0 .../frontend/vite.config.ts | 0 .../frontend/vite/aliases.ts | 0 .../frontend/vite/build-all.mjs | 0 .../frontend/vite/plugins.ts | 0 .../frontend/vite/surfaces.ts | 0 {tools/src/templates => templates}/gitignore | 0 .../templates => templates}/plugin/Provider.php | 0 .../src/templates => templates}/plugin/config.php | 0 .../src/templates => templates}/plugin/factory.php | 0 .../templates => templates}/plugin/migration.php | 0 .../src/templates => templates}/plugin/module.json | 0 .../src/templates => templates}/plugin/seeder.php | 0 {tools/src/templates => templates}/plugin/view.php | 0 {tools/src/templates => templates}/proj.json | 0 .../templates => templates}/resources/welcome.php | 0 .../src/Application/GreetingService.php | 0 .../src/Domain/Greeting.php | 0 .../src/Infrastructure/Http/HomeController.php | 0 {tools/src/templates => templates}/src/README.md | 0 tools/bundle.sh | 9 +++++---- tools/src/commands/new.zig | 8 ++++---- tools/src/lib/services.zig | 14 +++++++------- 109 files changed, 21 insertions(+), 15 deletions(-) rename {tools/src/templates => templates}/README.md (100%) rename {tools/src/templates => templates}/app/bootstrap/app.php (100%) rename {tools/src/templates => templates}/app/bootstrap/kernel-autoload.php (100%) rename {tools/src/templates => templates}/app/cli/run.php (100%) rename {tools/src/templates => templates}/app/public/index.php (100%) rename {tools/src/templates => templates}/app/swoole/index.php (100%) rename {tools/src/templates => templates}/app/worker/run.php (100%) rename {tools/src/templates => templates}/composer.json (100%) rename {tools/src/templates => templates}/config/environments/local.php (100%) rename {tools/src/templates => templates}/config/environments/production.php (100%) rename {tools/src/templates => templates}/config/environments/staging.php (100%) rename {tools/src/templates => templates}/config/environments/testing.php (100%) rename {tools/src/templates => templates}/config/let-migrate.php (100%) rename {tools/src/templates => templates}/config/storage.php (100%) rename {tools/src/templates => templates}/env.example (100%) rename {tools/src/templates => templates}/frontend/.gitignore (100%) rename {tools/src/templates => templates}/frontend/README.md (100%) rename {tools/src/templates => templates}/frontend/components.json (100%) rename {tools/src/templates => templates}/frontend/docs/HOW_IT_WORKS.md (100%) rename {tools/src/templates => templates}/frontend/index.html (100%) rename {tools/src/templates => templates}/frontend/package.json (100%) rename {tools/src/templates => templates}/frontend/src/shared/hooks/use-toast.ts (100%) rename {tools/src/templates => templates}/frontend/src/shared/lib/utils.ts (100%) rename {tools/src/templates => templates}/frontend/src/shared/providers/theme.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/styles/theme.css (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/accordion.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/alert-dialog.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/alert.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/aspect-ratio.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/avatar.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/badge.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/breadcrumb.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/button.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/calendar.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/card.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/carousel.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/chart.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/checkbox.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/collapsible.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/command.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/context-menu.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/dialog.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/drawer.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/dropdown-menu.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/form-standalone.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/form.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/hover-card.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/input-otp.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/input.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/label.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/menubar.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/navigation-menu.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/page-transition.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/pagination.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/popover.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/progress.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/radio-group.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/resizable.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/scroll-area.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/select.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/separator.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/sheet.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/skeleton.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/slider.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/sonner.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/switch.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/table.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/tabs.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/textarea.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/toast.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/toaster.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/toggle-group.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/toggle.tsx (100%) rename {tools/src/templates => templates}/frontend/src/shared/ui/tooltip.tsx (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/admin/Pages/Dashboard.tsx (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/admin/Pages/Login.tsx (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/admin/Pages/Users/Index.tsx (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/admin/index.tsx (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/admin/styles/index.css (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/admin/surface.json (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/project/Pages/About.tsx (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/project/Pages/Home.tsx (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/project/index.tsx (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/project/styles/index.css (100%) rename {tools/src/templates => templates}/frontend/src/surfaces/project/surface.json (100%) rename {tools/src/templates => templates}/frontend/tsconfig.json (100%) rename {tools/src/templates => templates}/frontend/vite.config.ts (100%) rename {tools/src/templates => templates}/frontend/vite/aliases.ts (100%) rename {tools/src/templates => templates}/frontend/vite/build-all.mjs (100%) rename {tools/src/templates => templates}/frontend/vite/plugins.ts (100%) rename {tools/src/templates => templates}/frontend/vite/surfaces.ts (100%) rename {tools/src/templates => templates}/gitignore (100%) rename {tools/src/templates => templates}/plugin/Provider.php (100%) rename {tools/src/templates => templates}/plugin/config.php (100%) rename {tools/src/templates => templates}/plugin/factory.php (100%) rename {tools/src/templates => templates}/plugin/migration.php (100%) rename {tools/src/templates => templates}/plugin/module.json (100%) rename {tools/src/templates => templates}/plugin/seeder.php (100%) rename {tools/src/templates => templates}/plugin/view.php (100%) rename {tools/src/templates => templates}/proj.json (100%) rename {tools/src/templates => templates}/resources/welcome.php (100%) rename {tools/src/templates => templates}/src/Application/GreetingService.php (100%) rename {tools/src/templates => templates}/src/Domain/Greeting.php (100%) rename {tools/src/templates => templates}/src/Infrastructure/Http/HomeController.php (100%) rename {tools/src/templates => templates}/src/README.md (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f728232..8a07a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Scaffolding templates moved from `tools/src/templates/` to a top-level + `templates/` directory so they ship inside the kernel payload. `tools/` is not + bundled, which previously broke `hkm new` / `hkm ui init` on packaged installs. + ### Fixed - `hkm run` / `hkm run --pick` / the registry now **self-locate the installed kernel** (`/opt/hkm-kernel`, or the dir relative to the launcher) instead of diff --git a/tools/src/templates/README.md b/templates/README.md similarity index 100% rename from tools/src/templates/README.md rename to templates/README.md diff --git a/tools/src/templates/app/bootstrap/app.php b/templates/app/bootstrap/app.php similarity index 100% rename from tools/src/templates/app/bootstrap/app.php rename to templates/app/bootstrap/app.php diff --git a/tools/src/templates/app/bootstrap/kernel-autoload.php b/templates/app/bootstrap/kernel-autoload.php similarity index 100% rename from tools/src/templates/app/bootstrap/kernel-autoload.php rename to templates/app/bootstrap/kernel-autoload.php diff --git a/tools/src/templates/app/cli/run.php b/templates/app/cli/run.php similarity index 100% rename from tools/src/templates/app/cli/run.php rename to templates/app/cli/run.php diff --git a/tools/src/templates/app/public/index.php b/templates/app/public/index.php similarity index 100% rename from tools/src/templates/app/public/index.php rename to templates/app/public/index.php diff --git a/tools/src/templates/app/swoole/index.php b/templates/app/swoole/index.php similarity index 100% rename from tools/src/templates/app/swoole/index.php rename to templates/app/swoole/index.php diff --git a/tools/src/templates/app/worker/run.php b/templates/app/worker/run.php similarity index 100% rename from tools/src/templates/app/worker/run.php rename to templates/app/worker/run.php diff --git a/tools/src/templates/composer.json b/templates/composer.json similarity index 100% rename from tools/src/templates/composer.json rename to templates/composer.json diff --git a/tools/src/templates/config/environments/local.php b/templates/config/environments/local.php similarity index 100% rename from tools/src/templates/config/environments/local.php rename to templates/config/environments/local.php diff --git a/tools/src/templates/config/environments/production.php b/templates/config/environments/production.php similarity index 100% rename from tools/src/templates/config/environments/production.php rename to templates/config/environments/production.php diff --git a/tools/src/templates/config/environments/staging.php b/templates/config/environments/staging.php similarity index 100% rename from tools/src/templates/config/environments/staging.php rename to templates/config/environments/staging.php diff --git a/tools/src/templates/config/environments/testing.php b/templates/config/environments/testing.php similarity index 100% rename from tools/src/templates/config/environments/testing.php rename to templates/config/environments/testing.php diff --git a/tools/src/templates/config/let-migrate.php b/templates/config/let-migrate.php similarity index 100% rename from tools/src/templates/config/let-migrate.php rename to templates/config/let-migrate.php diff --git a/tools/src/templates/config/storage.php b/templates/config/storage.php similarity index 100% rename from tools/src/templates/config/storage.php rename to templates/config/storage.php diff --git a/tools/src/templates/env.example b/templates/env.example similarity index 100% rename from tools/src/templates/env.example rename to templates/env.example diff --git a/tools/src/templates/frontend/.gitignore b/templates/frontend/.gitignore similarity index 100% rename from tools/src/templates/frontend/.gitignore rename to templates/frontend/.gitignore diff --git a/tools/src/templates/frontend/README.md b/templates/frontend/README.md similarity index 100% rename from tools/src/templates/frontend/README.md rename to templates/frontend/README.md diff --git a/tools/src/templates/frontend/components.json b/templates/frontend/components.json similarity index 100% rename from tools/src/templates/frontend/components.json rename to templates/frontend/components.json diff --git a/tools/src/templates/frontend/docs/HOW_IT_WORKS.md b/templates/frontend/docs/HOW_IT_WORKS.md similarity index 100% rename from tools/src/templates/frontend/docs/HOW_IT_WORKS.md rename to templates/frontend/docs/HOW_IT_WORKS.md diff --git a/tools/src/templates/frontend/index.html b/templates/frontend/index.html similarity index 100% rename from tools/src/templates/frontend/index.html rename to templates/frontend/index.html diff --git a/tools/src/templates/frontend/package.json b/templates/frontend/package.json similarity index 100% rename from tools/src/templates/frontend/package.json rename to templates/frontend/package.json diff --git a/tools/src/templates/frontend/src/shared/hooks/use-toast.ts b/templates/frontend/src/shared/hooks/use-toast.ts similarity index 100% rename from tools/src/templates/frontend/src/shared/hooks/use-toast.ts rename to templates/frontend/src/shared/hooks/use-toast.ts diff --git a/tools/src/templates/frontend/src/shared/lib/utils.ts b/templates/frontend/src/shared/lib/utils.ts similarity index 100% rename from tools/src/templates/frontend/src/shared/lib/utils.ts rename to templates/frontend/src/shared/lib/utils.ts diff --git a/tools/src/templates/frontend/src/shared/providers/theme.tsx b/templates/frontend/src/shared/providers/theme.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/providers/theme.tsx rename to templates/frontend/src/shared/providers/theme.tsx diff --git a/tools/src/templates/frontend/src/shared/styles/theme.css b/templates/frontend/src/shared/styles/theme.css similarity index 100% rename from tools/src/templates/frontend/src/shared/styles/theme.css rename to templates/frontend/src/shared/styles/theme.css diff --git a/tools/src/templates/frontend/src/shared/ui/accordion.tsx b/templates/frontend/src/shared/ui/accordion.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/accordion.tsx rename to templates/frontend/src/shared/ui/accordion.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/alert-dialog.tsx b/templates/frontend/src/shared/ui/alert-dialog.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/alert-dialog.tsx rename to templates/frontend/src/shared/ui/alert-dialog.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/alert.tsx b/templates/frontend/src/shared/ui/alert.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/alert.tsx rename to templates/frontend/src/shared/ui/alert.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/aspect-ratio.tsx b/templates/frontend/src/shared/ui/aspect-ratio.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/aspect-ratio.tsx rename to templates/frontend/src/shared/ui/aspect-ratio.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/avatar.tsx b/templates/frontend/src/shared/ui/avatar.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/avatar.tsx rename to templates/frontend/src/shared/ui/avatar.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/badge.tsx b/templates/frontend/src/shared/ui/badge.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/badge.tsx rename to templates/frontend/src/shared/ui/badge.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/breadcrumb.tsx b/templates/frontend/src/shared/ui/breadcrumb.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/breadcrumb.tsx rename to templates/frontend/src/shared/ui/breadcrumb.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/button.tsx b/templates/frontend/src/shared/ui/button.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/button.tsx rename to templates/frontend/src/shared/ui/button.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/calendar.tsx b/templates/frontend/src/shared/ui/calendar.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/calendar.tsx rename to templates/frontend/src/shared/ui/calendar.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/card.tsx b/templates/frontend/src/shared/ui/card.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/card.tsx rename to templates/frontend/src/shared/ui/card.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/carousel.tsx b/templates/frontend/src/shared/ui/carousel.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/carousel.tsx rename to templates/frontend/src/shared/ui/carousel.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/chart.tsx b/templates/frontend/src/shared/ui/chart.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/chart.tsx rename to templates/frontend/src/shared/ui/chart.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/checkbox.tsx b/templates/frontend/src/shared/ui/checkbox.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/checkbox.tsx rename to templates/frontend/src/shared/ui/checkbox.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/collapsible.tsx b/templates/frontend/src/shared/ui/collapsible.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/collapsible.tsx rename to templates/frontend/src/shared/ui/collapsible.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/command.tsx b/templates/frontend/src/shared/ui/command.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/command.tsx rename to templates/frontend/src/shared/ui/command.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/context-menu.tsx b/templates/frontend/src/shared/ui/context-menu.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/context-menu.tsx rename to templates/frontend/src/shared/ui/context-menu.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/dialog.tsx b/templates/frontend/src/shared/ui/dialog.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/dialog.tsx rename to templates/frontend/src/shared/ui/dialog.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/drawer.tsx b/templates/frontend/src/shared/ui/drawer.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/drawer.tsx rename to templates/frontend/src/shared/ui/drawer.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/dropdown-menu.tsx b/templates/frontend/src/shared/ui/dropdown-menu.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/dropdown-menu.tsx rename to templates/frontend/src/shared/ui/dropdown-menu.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/form-standalone.tsx b/templates/frontend/src/shared/ui/form-standalone.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/form-standalone.tsx rename to templates/frontend/src/shared/ui/form-standalone.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/form.tsx b/templates/frontend/src/shared/ui/form.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/form.tsx rename to templates/frontend/src/shared/ui/form.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/hover-card.tsx b/templates/frontend/src/shared/ui/hover-card.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/hover-card.tsx rename to templates/frontend/src/shared/ui/hover-card.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/input-otp.tsx b/templates/frontend/src/shared/ui/input-otp.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/input-otp.tsx rename to templates/frontend/src/shared/ui/input-otp.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/input.tsx b/templates/frontend/src/shared/ui/input.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/input.tsx rename to templates/frontend/src/shared/ui/input.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/label.tsx b/templates/frontend/src/shared/ui/label.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/label.tsx rename to templates/frontend/src/shared/ui/label.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/menubar.tsx b/templates/frontend/src/shared/ui/menubar.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/menubar.tsx rename to templates/frontend/src/shared/ui/menubar.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/navigation-menu.tsx b/templates/frontend/src/shared/ui/navigation-menu.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/navigation-menu.tsx rename to templates/frontend/src/shared/ui/navigation-menu.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/page-transition.tsx b/templates/frontend/src/shared/ui/page-transition.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/page-transition.tsx rename to templates/frontend/src/shared/ui/page-transition.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/pagination.tsx b/templates/frontend/src/shared/ui/pagination.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/pagination.tsx rename to templates/frontend/src/shared/ui/pagination.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/popover.tsx b/templates/frontend/src/shared/ui/popover.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/popover.tsx rename to templates/frontend/src/shared/ui/popover.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/progress.tsx b/templates/frontend/src/shared/ui/progress.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/progress.tsx rename to templates/frontend/src/shared/ui/progress.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/radio-group.tsx b/templates/frontend/src/shared/ui/radio-group.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/radio-group.tsx rename to templates/frontend/src/shared/ui/radio-group.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/resizable.tsx b/templates/frontend/src/shared/ui/resizable.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/resizable.tsx rename to templates/frontend/src/shared/ui/resizable.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/scroll-area.tsx b/templates/frontend/src/shared/ui/scroll-area.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/scroll-area.tsx rename to templates/frontend/src/shared/ui/scroll-area.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/select.tsx b/templates/frontend/src/shared/ui/select.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/select.tsx rename to templates/frontend/src/shared/ui/select.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/separator.tsx b/templates/frontend/src/shared/ui/separator.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/separator.tsx rename to templates/frontend/src/shared/ui/separator.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/sheet.tsx b/templates/frontend/src/shared/ui/sheet.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/sheet.tsx rename to templates/frontend/src/shared/ui/sheet.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/skeleton.tsx b/templates/frontend/src/shared/ui/skeleton.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/skeleton.tsx rename to templates/frontend/src/shared/ui/skeleton.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/slider.tsx b/templates/frontend/src/shared/ui/slider.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/slider.tsx rename to templates/frontend/src/shared/ui/slider.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/sonner.tsx b/templates/frontend/src/shared/ui/sonner.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/sonner.tsx rename to templates/frontend/src/shared/ui/sonner.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/switch.tsx b/templates/frontend/src/shared/ui/switch.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/switch.tsx rename to templates/frontend/src/shared/ui/switch.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/table.tsx b/templates/frontend/src/shared/ui/table.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/table.tsx rename to templates/frontend/src/shared/ui/table.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/tabs.tsx b/templates/frontend/src/shared/ui/tabs.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/tabs.tsx rename to templates/frontend/src/shared/ui/tabs.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/textarea.tsx b/templates/frontend/src/shared/ui/textarea.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/textarea.tsx rename to templates/frontend/src/shared/ui/textarea.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/toast.tsx b/templates/frontend/src/shared/ui/toast.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/toast.tsx rename to templates/frontend/src/shared/ui/toast.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/toaster.tsx b/templates/frontend/src/shared/ui/toaster.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/toaster.tsx rename to templates/frontend/src/shared/ui/toaster.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/toggle-group.tsx b/templates/frontend/src/shared/ui/toggle-group.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/toggle-group.tsx rename to templates/frontend/src/shared/ui/toggle-group.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/toggle.tsx b/templates/frontend/src/shared/ui/toggle.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/toggle.tsx rename to templates/frontend/src/shared/ui/toggle.tsx diff --git a/tools/src/templates/frontend/src/shared/ui/tooltip.tsx b/templates/frontend/src/shared/ui/tooltip.tsx similarity index 100% rename from tools/src/templates/frontend/src/shared/ui/tooltip.tsx rename to templates/frontend/src/shared/ui/tooltip.tsx diff --git a/tools/src/templates/frontend/src/surfaces/admin/Pages/Dashboard.tsx b/templates/frontend/src/surfaces/admin/Pages/Dashboard.tsx similarity index 100% rename from tools/src/templates/frontend/src/surfaces/admin/Pages/Dashboard.tsx rename to templates/frontend/src/surfaces/admin/Pages/Dashboard.tsx diff --git a/tools/src/templates/frontend/src/surfaces/admin/Pages/Login.tsx b/templates/frontend/src/surfaces/admin/Pages/Login.tsx similarity index 100% rename from tools/src/templates/frontend/src/surfaces/admin/Pages/Login.tsx rename to templates/frontend/src/surfaces/admin/Pages/Login.tsx diff --git a/tools/src/templates/frontend/src/surfaces/admin/Pages/Users/Index.tsx b/templates/frontend/src/surfaces/admin/Pages/Users/Index.tsx similarity index 100% rename from tools/src/templates/frontend/src/surfaces/admin/Pages/Users/Index.tsx rename to templates/frontend/src/surfaces/admin/Pages/Users/Index.tsx diff --git a/tools/src/templates/frontend/src/surfaces/admin/index.tsx b/templates/frontend/src/surfaces/admin/index.tsx similarity index 100% rename from tools/src/templates/frontend/src/surfaces/admin/index.tsx rename to templates/frontend/src/surfaces/admin/index.tsx diff --git a/tools/src/templates/frontend/src/surfaces/admin/styles/index.css b/templates/frontend/src/surfaces/admin/styles/index.css similarity index 100% rename from tools/src/templates/frontend/src/surfaces/admin/styles/index.css rename to templates/frontend/src/surfaces/admin/styles/index.css diff --git a/tools/src/templates/frontend/src/surfaces/admin/surface.json b/templates/frontend/src/surfaces/admin/surface.json similarity index 100% rename from tools/src/templates/frontend/src/surfaces/admin/surface.json rename to templates/frontend/src/surfaces/admin/surface.json diff --git a/tools/src/templates/frontend/src/surfaces/project/Pages/About.tsx b/templates/frontend/src/surfaces/project/Pages/About.tsx similarity index 100% rename from tools/src/templates/frontend/src/surfaces/project/Pages/About.tsx rename to templates/frontend/src/surfaces/project/Pages/About.tsx diff --git a/tools/src/templates/frontend/src/surfaces/project/Pages/Home.tsx b/templates/frontend/src/surfaces/project/Pages/Home.tsx similarity index 100% rename from tools/src/templates/frontend/src/surfaces/project/Pages/Home.tsx rename to templates/frontend/src/surfaces/project/Pages/Home.tsx diff --git a/tools/src/templates/frontend/src/surfaces/project/index.tsx b/templates/frontend/src/surfaces/project/index.tsx similarity index 100% rename from tools/src/templates/frontend/src/surfaces/project/index.tsx rename to templates/frontend/src/surfaces/project/index.tsx diff --git a/tools/src/templates/frontend/src/surfaces/project/styles/index.css b/templates/frontend/src/surfaces/project/styles/index.css similarity index 100% rename from tools/src/templates/frontend/src/surfaces/project/styles/index.css rename to templates/frontend/src/surfaces/project/styles/index.css diff --git a/tools/src/templates/frontend/src/surfaces/project/surface.json b/templates/frontend/src/surfaces/project/surface.json similarity index 100% rename from tools/src/templates/frontend/src/surfaces/project/surface.json rename to templates/frontend/src/surfaces/project/surface.json diff --git a/tools/src/templates/frontend/tsconfig.json b/templates/frontend/tsconfig.json similarity index 100% rename from tools/src/templates/frontend/tsconfig.json rename to templates/frontend/tsconfig.json diff --git a/tools/src/templates/frontend/vite.config.ts b/templates/frontend/vite.config.ts similarity index 100% rename from tools/src/templates/frontend/vite.config.ts rename to templates/frontend/vite.config.ts diff --git a/tools/src/templates/frontend/vite/aliases.ts b/templates/frontend/vite/aliases.ts similarity index 100% rename from tools/src/templates/frontend/vite/aliases.ts rename to templates/frontend/vite/aliases.ts diff --git a/tools/src/templates/frontend/vite/build-all.mjs b/templates/frontend/vite/build-all.mjs similarity index 100% rename from tools/src/templates/frontend/vite/build-all.mjs rename to templates/frontend/vite/build-all.mjs diff --git a/tools/src/templates/frontend/vite/plugins.ts b/templates/frontend/vite/plugins.ts similarity index 100% rename from tools/src/templates/frontend/vite/plugins.ts rename to templates/frontend/vite/plugins.ts diff --git a/tools/src/templates/frontend/vite/surfaces.ts b/templates/frontend/vite/surfaces.ts similarity index 100% rename from tools/src/templates/frontend/vite/surfaces.ts rename to templates/frontend/vite/surfaces.ts diff --git a/tools/src/templates/gitignore b/templates/gitignore similarity index 100% rename from tools/src/templates/gitignore rename to templates/gitignore diff --git a/tools/src/templates/plugin/Provider.php b/templates/plugin/Provider.php similarity index 100% rename from tools/src/templates/plugin/Provider.php rename to templates/plugin/Provider.php diff --git a/tools/src/templates/plugin/config.php b/templates/plugin/config.php similarity index 100% rename from tools/src/templates/plugin/config.php rename to templates/plugin/config.php diff --git a/tools/src/templates/plugin/factory.php b/templates/plugin/factory.php similarity index 100% rename from tools/src/templates/plugin/factory.php rename to templates/plugin/factory.php diff --git a/tools/src/templates/plugin/migration.php b/templates/plugin/migration.php similarity index 100% rename from tools/src/templates/plugin/migration.php rename to templates/plugin/migration.php diff --git a/tools/src/templates/plugin/module.json b/templates/plugin/module.json similarity index 100% rename from tools/src/templates/plugin/module.json rename to templates/plugin/module.json diff --git a/tools/src/templates/plugin/seeder.php b/templates/plugin/seeder.php similarity index 100% rename from tools/src/templates/plugin/seeder.php rename to templates/plugin/seeder.php diff --git a/tools/src/templates/plugin/view.php b/templates/plugin/view.php similarity index 100% rename from tools/src/templates/plugin/view.php rename to templates/plugin/view.php diff --git a/tools/src/templates/proj.json b/templates/proj.json similarity index 100% rename from tools/src/templates/proj.json rename to templates/proj.json diff --git a/tools/src/templates/resources/welcome.php b/templates/resources/welcome.php similarity index 100% rename from tools/src/templates/resources/welcome.php rename to templates/resources/welcome.php diff --git a/tools/src/templates/src/Application/GreetingService.php b/templates/src/Application/GreetingService.php similarity index 100% rename from tools/src/templates/src/Application/GreetingService.php rename to templates/src/Application/GreetingService.php diff --git a/tools/src/templates/src/Domain/Greeting.php b/templates/src/Domain/Greeting.php similarity index 100% rename from tools/src/templates/src/Domain/Greeting.php rename to templates/src/Domain/Greeting.php diff --git a/tools/src/templates/src/Infrastructure/Http/HomeController.php b/templates/src/Infrastructure/Http/HomeController.php similarity index 100% rename from tools/src/templates/src/Infrastructure/Http/HomeController.php rename to templates/src/Infrastructure/Http/HomeController.php diff --git a/tools/src/templates/src/README.md b/templates/src/README.md similarity index 100% rename from tools/src/templates/src/README.md rename to templates/src/README.md diff --git a/tools/bundle.sh b/tools/bundle.sh index b38c4b6..0c4fbc4 100755 --- a/tools/bundle.sh +++ b/tools/bundle.sh @@ -46,7 +46,7 @@ MODULES="${MODULES:-bundle}" # vendor/ is ALWAYS excluded — composer resolves it on the target. Everything # staged is git-tracked ⇒ no gitignored junk (.claude, node_modules, var/cache, # submodule vendors) can leak. -SRC_PATHS="src plugins projects composer.json composer.lock bin/psp README.md LICENSE" +SRC_PATHS="src plugins projects templates composer.json composer.lock bin/psp README.md LICENSE" [ "$MODULES" = bundle ] && SRC_PATHS="$SRC_PATHS modules" # Emit modules.lock: " " per submodule, from the SHA @@ -81,11 +81,12 @@ stage_kernel() { # $1 = destination kernel root if [ -f "$k/bin/psp" ]; then mv "$k/bin/psp" "$k/bin/hkm"; chmod +x "$k/bin/hkm"; fi # Runtime install ships NO documentation or build tooling: strip every `docs`/ - # `doc` and `tools` directory + leftover test caches from the staged tree. + # `doc` and `tools` directory + leftover test caches. The templates/ subtree is + # EXEMPT — a scaffolded project legitimately ships its own docs/tests/tooling. find "$k" -depth -type d \( -name docs -o -name doc -o -name tools -o -name tests \ - -o -name .git -o -name .github \) -exec rm -rf {} + 2>/dev/null || true + -o -name .git -o -name .github \) -not -path "$k/templates/*" -exec rm -rf {} + 2>/dev/null || true find "$k" -type f \( -name '.phpunit.result.cache' -o -name '.gitignore' \ - -o -name '.gitattributes' \) -delete 2>/dev/null || true + -o -name '.gitattributes' \) -not -path "$k/templates/*" -delete 2>/dev/null || true # Drop the composer-install helper used on non-.deb targets (macOS/Windows). cp "$TOOLS/templates/install-kernel.sh" "$k/install.sh" 2>/dev/null || true diff --git a/tools/src/commands/new.zig b/tools/src/commands/new.zig index a52f240..b3c70db 100644 --- a/tools/src/commands/new.zig +++ b/tools/src/commands/new.zig @@ -9,16 +9,16 @@ //! project with NO PHP / Composer present — only `composer install` is needed //! afterwards to pull the kernel + plugins. //! -//! The generated files are kept as real templates under `tools/src/templates/` +//! The generated files are kept as real templates under `templates/` //! and read from a templates DIRECTORY at runtime — they are NOT embedded in the //! binary, so they can be edited without recompiling. Resolution order for the //! directory (first hit wins): //! //! 1. HKM_TEMPLATES_DIR (explicit override) -//! 2. HKM_KERNEL_HOME/tools/src/templates (dev / installed kernel) +//! 2. HKM_KERNEL_HOME/templates (dev / installed kernel) //! 3. /templates (packaged alongside binary) //! 4. /../share/hkm/templates (packaged FHS layout) -//! 5. /tools/src/templates (inferred from the registry) +//! 5. /templates (inferred from the registry) //! //! If none can be found, `new` fails with a clear message — there is no //! compiled-in fallback. Templates use three tokens substituted per project: @@ -44,7 +44,7 @@ const Template = struct { }; /// Every file the scaffolder writes. `src` is read from the resolved templates -/// dir at runtime (relative to tools/src/templates/). +/// dir at runtime (relative to the templates dir). const templates = [_]Template{ .{ .dest = "proj.json", .src = "proj.json" }, .{ .dest = "composer.json", .src = "composer.json" }, diff --git a/tools/src/lib/services.zig b/tools/src/lib/services.zig index cc496eb..d0a7036 100644 --- a/tools/src/lib/services.zig +++ b/tools/src/lib/services.zig @@ -96,11 +96,11 @@ pub fn resolveTemplatesDir(allocator: std.mem.Allocator, io: Io, env: *EnvMap) ! if (env.get("HKM_TEMPLATES_DIR")) |d| { if (d.len > 0) return util.trimSlash(d); } - if (env.get("HKM_KERNEL_HOME")) |h| { - if (h.len > 0) { - const c = try std.fmt.allocPrint(allocator, "{s}/tools/src/templates", .{util.trimSlash(h)}); - if (templatesDirOk(io, c)) return c; - } + // The templates ship INSIDE the kernel payload at /templates (they + // used to live under tools/, which is not shipped). Self-locate the kernel. + if (try kernel.resolveHome(allocator, io, env)) |home| { + const c = try std.fmt.allocPrint(allocator, "{s}/templates", .{home}); + if (templatesDirOk(io, c)) return c; } // Locations relative to the installed executable (packaged distributions). if (std.process.executableDirPathAlloc(io, allocator)) |exe_dir| { @@ -109,10 +109,10 @@ pub fn resolveTemplatesDir(allocator: std.mem.Allocator, io: Io, env: *EnvMap) ! const fhs = try std.fmt.allocPrint(allocator, "{s}/../share/hkm/templates", .{util.trimSlash(exe_dir)}); if (templatesDirOk(io, fhs)) return fhs; } else |_| {} - // Infer the kernel root from the registry path: /projects/projects.json. + // Fallback: infer the kernel root from the registry path. if (try registry.resolvePath(allocator, io, env)) |jsonPath| { if (util.parentOf(util.parentOf(jsonPath))) |kernel_root| { - const c = try std.fmt.allocPrint(allocator, "{s}/tools/src/templates", .{kernel_root}); + const c = try std.fmt.allocPrint(allocator, "{s}/templates", .{kernel_root}); if (templatesDirOk(io, c)) return c; } } From e60838b60c91abc4c0d5c4d46062b5987a1c3a87 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 00:41:28 +0300 Subject: [PATCH 006/140] chore(release): v1.0.3 Kernel self-location, real hkm-config, config.env loading, and templates shipped inside the kernel. --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a07a85..f6d9f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.3] - 2026-07-08 + ### Changed - Scaffolding templates moved from `tools/src/templates/` to a top-level `templates/` directory so they ship inside the kernel payload. `tools/` is not @@ -82,5 +84,8 @@ macOS, and Windows, built and published automatically from a `v*` tag. (`phpunit.xml` is gitignored). - Windows cross-compilation: guarded POSIX-only raw-mode TTY code. -[Unreleased]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.3...HEAD +[1.0.3]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/AlfaCode-Team/php-service-platform/releases/tag/v1.0.0 From 1b085b41f139847c8a331ad6e1a09fe5cb8bc98e Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 01:01:30 +0300 Subject: [PATCH 007/140] chore(cli): empty the committed project registry + auto-clear hook + help note - projects/projects.json is committed empty ({}) so developer-local registrations (and machine paths) never ship in the repo or bundles. - .githooks/pre-commit forces projects.json to {} in every commit; enable with: git config core.hooksPath .githooks - hkm help notes the env vars are auto-detected (override only if needed). --- .githooks/pre-commit | 14 ++++++++++++++ projects/projects.json | 21 +-------------------- tools/src/main.zig | 1 + 3 files changed, 16 insertions(+), 20 deletions(-) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..1f10c4a --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,14 @@ +#!/bin/sh +# Never commit developer-local project registrations: force +# projects/projects.json to an empty registry in the commit (the working copy +# is left untouched, so your local `hkm` registrations keep working). +f="projects/projects.json" +if git diff --cached --name-only -- "$f" | grep -q .; then + staged="$(git show ":$f" 2>/dev/null | tr -d ' \t\r\n')" + if [ "$staged" != "{}" ]; then + blob="$(printf '{}\n' | git hash-object -w --stdin)" + git update-index --cacheinfo "100644,$blob,$f" + echo "pre-commit: reset $f to an empty registry (dev entries not committed)" + fi +fi +exit 0 diff --git a/projects/projects.json b/projects/projects.json index 2cf9004..0967ef4 100644 --- a/projects/projects.json +++ b/projects/projects.json @@ -1,20 +1 @@ -{ - "shop": { - "name": "shop", - "version": "1.0.0", - "path": "/home/home/Documents/PROJECTS/psp-shop", - "domains": [ - "shop.com" - ] - }, - "hkmcode": { - "name": "hkmcode", - "version": "1.0.0", - "path": "/home/home/Documents/PROJECTS/hkmcode", - "domains": [ - "hkm.local", - "api.hkm.local", - "app.hkm.local" - ] - } -} +{} diff --git a/tools/src/main.zig b/tools/src/main.zig index ab4c994..5f1cca1 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -32,6 +32,7 @@ fn printHelp() void { prompt.blank(); prompt.section("Environment"); + prompt.muted("all auto-detected — override only for a non-standard layout"); prompt.item("HKM_PHP_BIN", "override php binary (default: php)"); prompt.item("HKM_CLI_PATH", "override target php CLI script"); prompt.item("HKM_GLOBAL_AUTOLOAD", "override global autoload path"); From a1531b37f362539eb72c0861137808decb650ba7 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 01:18:23 +0300 Subject: [PATCH 008/140] chore: refresh contributor statistics From ead0a188ab650da09a9218ed8473569e65a2ec33 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 01:35:34 +0300 Subject: [PATCH 009/140] feat(userdata): HKM_USERDATA_DIR for persistent registry across updates projects.json + platform.json are user data. HKM_USERDATA_DIR relocates them outside the kernel install (honoured by the hkm CLI registry and the PHP DomainResolver), and the .deb marks them as conffiles so an in-place upgrade preserves the user's registrations. Falls back to /projects when unset. --- CHANGELOG.md | 8 +++++++ projects/Bootstrap/Domain/DomainResolver.php | 23 ++++++++++++++++++-- tools/bundle.sh | 8 +++++++ tools/src/lib/registry.zig | 5 +++++ tools/src/main.zig | 1 + 5 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d9f6d..8a7b762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `HKM_USERDATA_DIR` — relocate the persistent registry (`projects.json` + + `platform.json`) outside the kernel install so a kernel update never + overwrites it. Honoured by the `hkm` CLI (registry) and the PHP + `DomainResolver`; falls back to `/projects` when unset. +- The `.deb` marks `projects.json` + `platform.json` as dpkg conffiles, so an + in-place upgrade preserves a user's registrations even without relocating. + ## [1.0.3] - 2026-07-08 ### Changed diff --git a/projects/Bootstrap/Domain/DomainResolver.php b/projects/Bootstrap/Domain/DomainResolver.php index cc4d643..10241a2 100644 --- a/projects/Bootstrap/Domain/DomainResolver.php +++ b/projects/Bootstrap/Domain/DomainResolver.php @@ -168,17 +168,36 @@ private static function matchProject( /** * @return array{adminSubs: list, apiSubs: list, projects: array} */ + /** + * Directory holding the persistent registry files (projects.json, + * platform.json). HKM_USERDATA_DIR relocates them outside the kernel so an + * update never clobbers them; falls back to /projects for a checkout. + */ + private static function userdataDir(string $base): string + { + $dir = $_SERVER['HKM_USERDATA_DIR'] + ?? $_ENV['HKM_USERDATA_DIR'] + ?? getenv('HKM_USERDATA_DIR') + ?: null; + + return is_string($dir) && $dir !== '' ? rtrim($dir, '/') : $base . '/projects'; + } + private static function loadRegistries(string $base): array { if (isset(self::$cache[$base])) { return self::$cache[$base]; } - $platform = self::readJson($base . '/projects/platform.json'); + // The registry files are USER DATA: they live in HKM_USERDATA_DIR when + // set (persistent, outside the kernel install so an update cannot + // overwrite them), else under /projects for a dev checkout. + $userdata = self::userdataDir($base); + $platform = self::readJson($userdata . '/platform.json'); $adminSubs = self::stringList($platform['subdomains']['admin'] ?? null) ?: ['app']; $apiSubs = self::stringList($platform['subdomains']['api'] ?? null) ?: ['api']; - $projects = self::readJson($base . '/projects/projects.json'); + $projects = self::readJson($userdata . '/projects.json'); return self::$cache[$base] = [ 'adminSubs' => $adminSubs, diff --git a/tools/bundle.sh b/tools/bundle.sh index 0c4fbc4..5f5417a 100755 --- a/tools/bundle.sh +++ b/tools/bundle.sh @@ -127,6 +127,14 @@ Description: PhpServicePlatform (HKM) kernel and native launcher resolved with composer at install time (vendor/ is not bundled), so the runtime matches this machine's PHP. Needs network access during install. Run 'hkm doctor' afterwards to verify PHP and required extensions. +EOF + # conffiles: the project registry + platform map are USER DATA. Marking them as + # dpkg conffiles makes upgrades PRESERVE the user's versions instead of + # overwriting them with the packaged defaults. (Relocate entirely with + # HKM_USERDATA_DIR if you'd rather keep them outside /opt.) + cat > "$P/DEBIAN/conffiles" < "$P/DEBIAN/postinst" < 0) return try std.fmt.allocPrint(allocator, "{s}/projects.json", .{trimSlash(d)}); + } if (env.get("PSP_PROJECTS_DIR")) |d| { if (d.len > 0) return try std.fmt.allocPrint(allocator, "{s}/projects.json", .{trimSlash(d)}); } diff --git a/tools/src/main.zig b/tools/src/main.zig index 5f1cca1..f13f414 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -36,6 +36,7 @@ fn printHelp() void { prompt.item("HKM_PHP_BIN", "override php binary (default: php)"); prompt.item("HKM_CLI_PATH", "override target php CLI script"); prompt.item("HKM_GLOBAL_AUTOLOAD", "override global autoload path"); + prompt.item("HKM_USERDATA_DIR", "persistent registry dir (projects.json + platform.json); survives updates"); prompt.item("PSP_PROJECTS_DIR", "dir holding the kernel projects.json registry"); prompt.item("HKM_KERNEL_HOME", "kernel root (registry at /projects/projects.json)"); From 6bf2b11451146f3a587a6f83863aa86df2e3dec5 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 01:42:50 +0300 Subject: [PATCH 010/140] feat(config): hkm-config sets up the full environment (kernel + userdata) hkm-config now resolves/pins HKM_KERNEL_HOME AND provisions the persistent userdata dir: creates XDG_DATA_HOME/hkm (or ~/.local/share/hkm), migrates any existing registry into it, and pins HKM_USERDATA_DIR. One command configures everything the launcher and runtime need. --- CHANGELOG.md | 6 +++++ tools/src/config.zig | 57 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7b762..67fd513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The `.deb` marks `projects.json` + `platform.json` as dpkg conffiles, so an in-place upgrade preserves a user's registrations even without relocating. +### Changed +- `hkm-config` now sets up the FULL required environment in one run: it + resolves + pins `HKM_KERNEL_HOME`, and creates a persistent userdata dir + (`XDG_DATA_HOME/hkm` or `~/.local/share/hkm`), migrates any existing + registry into it, and pins `HKM_USERDATA_DIR`. + ## [1.0.3] - 2026-07-08 ### Changed diff --git a/tools/src/config.zig b/tools/src/config.zig index 45ba951..79d9035 100644 --- a/tools/src/config.zig +++ b/tools/src/config.zig @@ -112,6 +112,19 @@ fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { wrote = true; } + // 4. Ensure a PERSISTENT userdata dir holds the registry (projects.json + + // platform.json) so kernel updates never overwrite it. Seed from the + // kernel defaults, then pin HKM_USERDATA_DIR. + const userdata = try ensureUserdata(allocator, io, env, home); + if (userdata) |ud| { + prompt.item("userdata dir", ud); + const cur = try userconfig.get(allocator, io, env, "HKM_USERDATA_DIR"); + if (cur == null or !std.mem.eql(u8, cur.?, ud)) { + try userconfig.set(allocator, io, env, "HKM_USERDATA_DIR", ud); + wrote = true; + } + } + prompt.blank(); if (!have_vendor) { prompt.warn("kernel dependencies are not installed."); @@ -125,10 +138,52 @@ fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { } if (wrote) { - prompt.ok("configuration written — HKM_KERNEL_HOME pinned."); + prompt.ok("configuration written — HKM_KERNEL_HOME + HKM_USERDATA_DIR pinned."); } else { prompt.ok("configuration is complete."); } prompt.muted("verify the runtime with: hkm doctor"); return 0; } + +/// Resolve (and create + seed) the persistent userdata directory that holds the +/// registry files. Order: existing HKM_USERDATA_DIR → XDG_DATA_HOME/hkm → +/// HOME/.local/share/hkm. Seeds projects.json ({}) and platform.json (copied +/// from the kernel default) when absent. Returns the dir, or null if unresolvable. +fn ensureUserdata(allocator: std.mem.Allocator, io: Io, env: *EnvMap, home: []const u8) !?[]const u8 { + const dir: []const u8 = blk: { + if (env.get("HKM_USERDATA_DIR")) |d| { + if (d.len > 0) break :blk try allocator.dupe(u8, d); + } + if (env.get("XDG_DATA_HOME")) |x| { + if (x.len > 0) break :blk try std.fs.path.join(allocator, &.{ x, "hkm" }); + } + if (env.get("HOME")) |h| { + if (h.len > 0) break :blk try std.fs.path.join(allocator, &.{ h, ".local", "share", "hkm" }); + } + return null; + }; + + const cwd = std.Io.Dir.cwd(); + cwd.createDirPath(io, dir) catch {}; + + // Seed projects.json: migrate an existing kernel registry if present (so + // relocating doesn't drop registrations), else start with an empty one. + const proj = try std.fs.path.join(allocator, &.{ dir, "projects.json" }); + if (!util.fileExists(io, proj)) { + const src = try std.fs.path.join(allocator, &.{ home, "projects", "projects.json" }); + const data = cwd.readFileAlloc(io, src, allocator, .limited(8 * 1024 * 1024)) catch "{}\n"; + cwd.writeFile(io, .{ .sub_path = proj, .data = data }) catch {}; + } + + // Seed platform.json from the kernel's shipped default, else a minimal map. + const plat = try std.fs.path.join(allocator, &.{ dir, "platform.json" }); + if (!util.fileExists(io, plat)) { + const src = try std.fs.path.join(allocator, &.{ home, "projects", "platform.json" }); + const data = cwd.readFileAlloc(io, src, allocator, .limited(1024 * 1024)) catch + "{\n \"subdomains\": { \"admin\": [\"app\"], \"api\": [\"api\"] }\n}\n"; + cwd.writeFile(io, .{ .sub_path = plat, .data = data }) catch {}; + } + + return dir; +} From 3a4f9e04006895270cf6320700b192c675ea5ee9 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 01:57:14 +0300 Subject: [PATCH 011/140] security: harden scaffolding defaults - .env (holds generated APP_KEY) written chmod 600; config.env too - debug output force-disabled when APP_ENV=production regardless of APP_DEBUG - new projects ship app/public/.htaccess (deny dotfiles, no listing, drop X-Powered-By, baseline security headers, front-controller rewrite) - env.example documents the production/secret-handling expectations --- CHANGELOG.md | 9 +++++++++ templates/app/public/.htaccess | 27 +++++++++++++++++++++++++++ templates/app/public/index.php | 5 ++++- templates/env.example | 3 +++ tools/src/commands/new.zig | 7 ++++++- tools/src/lib/userconfig.zig | 2 ++ tools/src/lib/util.zig | 9 +++++++++ 7 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 templates/app/public/.htaccess diff --git a/CHANGELOG.md b/CHANGELOG.md index 67fd513..3c47916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security +- Scaffolded `.env` (which holds the generated `APP_KEY`) is now written + `chmod 600` (owner-only); `~/.config/hkm/config.env` too. +- Debug output is force-disabled when `APP_ENV=production`, even if `APP_DEBUG` + was left `true` in a mis-set `.env` — production never leaks internals. +- New projects ship an `app/public/.htaccess`: denies dotfiles (`.env`, `.git`), + disables directory listing, drops `X-Powered-By`, adds baseline security + headers, and routes through the single front controller. + ### Added - `HKM_USERDATA_DIR` — relocate the persistent registry (`projects.json` + `platform.json`) outside the kernel install so a kernel update never diff --git a/templates/app/public/.htaccess b/templates/app/public/.htaccess new file mode 100644 index 0000000..c4c5186 --- /dev/null +++ b/templates/app/public/.htaccess @@ -0,0 +1,27 @@ +# Apache hardening + front-controller for app/public. +# (Nginx users: set root to app/public and try_files $uri /index.php?$query_string; +# and `location ~ /\. { deny all; }`.) + +# Never serve dotfiles (.env, .git, …) even if the docroot is misconfigured. + + Require all denied + + +# Do not expose the PHP version. + + Header always unset X-Powered-By + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "SAMEORIGIN" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + +# Disable directory listing. +Options -Indexes + +# Route every request through the single front controller. + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] + diff --git a/templates/app/public/index.php b/templates/app/public/index.php index 39225c8..aec43ba 100644 --- a/templates/app/public/index.php +++ b/templates/app/public/index.php @@ -56,7 +56,10 @@ // 4. Last-resort net for anything the kernel's own ErrorStage could not // handle. In debug we surface the message; in production we never leak // internals — just a generic 500. - $debug = filter_var($_ENV['APP_DEBUG'] ?? getenv('APP_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL); + // Debug output is NEVER shown in production, even if APP_DEBUG was left true + // (defense-in-depth against leaking internals from a mis-set .env). + $isProd = (($_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: 'production') === 'production'); + $debug = !$isProd && filter_var($_ENV['APP_DEBUG'] ?? getenv('APP_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL); Response::json([ 'error' => [ 'code' => 'kernel.unhandled', diff --git a/templates/env.example b/templates/env.example index 378eb2d..346c406 100644 --- a/templates/env.example +++ b/templates/env.example @@ -1,4 +1,7 @@ # Application Environment +# SECURITY: in production set APP_ENV=production and APP_DEBUG=false. Debug output +# is force-disabled when APP_ENV=production regardless, but never ship secrets in a +# world-readable .env — keep this file chmod 600 and OUTSIDE the web docroot. APP_ENV=local APP_DEBUG=true APP_NAME="{{PROJECT_NAME}}" diff --git a/tools/src/commands/new.zig b/tools/src/commands/new.zig index b3c70db..4a82382 100644 --- a/tools/src/commands/new.zig +++ b/tools/src/commands/new.zig @@ -55,6 +55,7 @@ const templates = [_]Template{ .{ .dest = "app/bootstrap/kernel-autoload.php", .src = "app/bootstrap/kernel-autoload.php" }, .{ .dest = "app/bootstrap/app.php", .src = "app/bootstrap/app.php" }, .{ .dest = "app/public/index.php", .src = "app/public/index.php" }, + .{ .dest = "app/public/.htaccess", .src = "app/public/.htaccess" }, .{ .dest = "app/swoole/index.php", .src = "app/swoole/index.php" }, .{ .dest = "app/cli/run.php", .src = "app/cli/run.php" }, .{ .dest = "app/worker/run.php", .src = "app/worker/run.php" }, @@ -333,7 +334,11 @@ fn generateAppKey(allocator: std.mem.Allocator, io: Io, opts: Options) !void { } try Dir.cwd().writeFile(io, .{ .sub_path = env_path, .data = out.items }); - prompt.ok("Generated APP_KEY in .env"); + + // The .env now holds the freshly generated APP_KEY (and will hold DB creds, + // JWT secrets, …). Lock it down to owner-only so it is never world-readable. + util.chmod600(io, env_path); + prompt.ok("Generated APP_KEY in .env (chmod 600)"); } /// Run `composer install` inside the new project. Inherits stdio so the user diff --git a/tools/src/lib/userconfig.zig b/tools/src/lib/userconfig.zig index 6bd853f..a86b2e5 100644 --- a/tools/src/lib/userconfig.zig +++ b/tools/src/lib/userconfig.zig @@ -89,4 +89,6 @@ pub fn set(allocator: std.mem.Allocator, io: Io, env: *EnvMap, key: []const u8, try out.append(allocator, '\n'); } try Dir.cwd().writeFile(io, .{ .sub_path = cfg, .data = out.items }); + // Owner-only: this file may later hold overrides an operator considers private. + @import("util.zig").chmod600(io, cfg); } diff --git a/tools/src/lib/util.zig b/tools/src/lib/util.zig index b8e454e..fd6391a 100644 --- a/tools/src/lib/util.zig +++ b/tools/src/lib/util.zig @@ -7,6 +7,15 @@ const Dir = std.Io.Dir; const Io = std.Io; const EnvMap = std.process.Environ.Map; +/// Restrict a file to owner-only (chmod 0600). No-op on Windows. Best-effort — +/// used for secret-bearing files like a project's .env. +pub fn chmod600(io: Io, path: []const u8) void { + if (@import("builtin").os.tag == .windows) return; + const f = Dir.cwd().openFile(io, path, .{}) catch return; + defer f.close(io); + f.setPermissions(io, @enumFromInt(0o600)) catch {}; +} + // ── path strings ──────────────────────────────────────────────────────────── /// Trim trailing path separators (keeps a lone "/"). From 2559aa1dbd1645ef8a40f6f83a3685912c49bc61 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 02:22:45 +0300 Subject: [PATCH 012/140] chore(release): v1.0.4 Security hardening (scaffolding perms, prod debug gate, Apache+nginx web config), HKM_USERDATA_DIR for persistent registry across updates, and hkm-config full-environment setup. --- CHANGELOG.md | 12 ++++++--- templates/app/nginx.conf.example | 46 ++++++++++++++++++++++++++++++++ tools/src/commands/new.zig | 1 + 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 templates/app/nginx.conf.example diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c47916..733f48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.4] - 2026-07-08 + ### Security - Scaffolded `.env` (which holds the generated `APP_KEY`) is now written `chmod 600` (owner-only); `~/.config/hkm/config.env` too. - Debug output is force-disabled when `APP_ENV=production`, even if `APP_DEBUG` was left `true` in a mis-set `.env` — production never leaks internals. -- New projects ship an `app/public/.htaccess`: denies dotfiles (`.env`, `.git`), - disables directory listing, drops `X-Powered-By`, adds baseline security - headers, and routes through the single front controller. +- New projects ship an `app/public/.htaccess` (Apache) and an + `app/nginx.conf.example` (nginx): deny dotfiles (`.env`, `.git`), disable + directory listing, drop `X-Powered-By`, add baseline security headers, and + route through the single front controller with docroot pinned to `app/public`. ### Added - `HKM_USERDATA_DIR` — relocate the persistent registry (`projects.json` + @@ -107,7 +110,8 @@ macOS, and Windows, built and published automatically from a `v*` tag. (`phpunit.xml` is gitignored). - Windows cross-compilation: guarded POSIX-only raw-mode TTY code. -[Unreleased]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.3...HEAD +[Unreleased]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.4...HEAD +[1.0.4]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.3...v1.0.4 [1.0.3]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.0...v1.0.1 diff --git a/templates/app/nginx.conf.example b/templates/app/nginx.conf.example new file mode 100644 index 0000000..d366f8f --- /dev/null +++ b/templates/app/nginx.conf.example @@ -0,0 +1,46 @@ +# Example nginx server block for a PhpServicePlatform project. +# Copy to your nginx sites, adjust server_name / paths / PHP socket, reload. +# The Apache equivalent ships as app/public/.htaccess. + +server { + listen 80; + server_name example.com; + + # Docroot is app/public ONLY — never the project root (keeps .env, config, + # src, vendor OUT of the web-served tree). + root /var/www/{{PROJECT_NAME}}/app/public; + index index.php; + + # Deny all dotfiles (.env, .git, .htaccess, …) even if placed under root. + location ~ /\. { + deny all; + return 404; + } + + # Never serve PHP files other than the front controller directly. + location ~ \.php$ { + # Only index.php is allowed to execute. + location = /index.php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/index.php; + # Match APP_ENV/HKM_USERDATA_DIR to your deployment: + fastcgi_param APP_ENV production; + # fastcgi_param HKM_USERDATA_DIR /var/lib/hkm; + fastcgi_pass unix:/run/php/php-fpm.sock; # adjust to your PHP-FPM socket + } + return 404; + } + + # Front controller: route everything else through index.php. + location / { + try_files $uri /index.php$is_args$args; + } + + # Baseline security headers (SecurityFilters plugin also sets these at runtime). + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + server_tokens off; + + client_max_body_size 25m; +} diff --git a/tools/src/commands/new.zig b/tools/src/commands/new.zig index 4a82382..b85629b 100644 --- a/tools/src/commands/new.zig +++ b/tools/src/commands/new.zig @@ -56,6 +56,7 @@ const templates = [_]Template{ .{ .dest = "app/bootstrap/app.php", .src = "app/bootstrap/app.php" }, .{ .dest = "app/public/index.php", .src = "app/public/index.php" }, .{ .dest = "app/public/.htaccess", .src = "app/public/.htaccess" }, + .{ .dest = "app/nginx.conf.example", .src = "app/nginx.conf.example" }, .{ .dest = "app/swoole/index.php", .src = "app/swoole/index.php" }, .{ .dest = "app/cli/run.php", .src = "app/cli/run.php" }, .{ .dest = "app/worker/run.php", .src = "app/worker/run.php" }, From 95110aa8afc75a47929339204f1a34c148da1783 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 02:44:30 +0300 Subject: [PATCH 013/140] feat(scaffold): ship an Apache vhost sample alongside nginx New projects scaffold app/apache.conf.example (DocumentRoot=app/public, deny dotfiles, only index.php executable, security headers). --- CHANGELOG.md | 5 +++ templates/app/apache.conf.example | 55 +++++++++++++++++++++++++++++++ tools/src/commands/new.zig | 1 + 3 files changed, 61 insertions(+) create mode 100644 templates/app/apache.conf.example diff --git a/CHANGELOG.md b/CHANGELOG.md index 733f48b..a80f098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- New projects also ship a full Apache virtual-host sample + (`app/apache.conf.example`) alongside the nginx one — DocumentRoot pinned to + `app/public`, dotfiles denied, only `index.php` executable, security headers. + ## [1.0.4] - 2026-07-08 ### Security diff --git a/templates/app/apache.conf.example b/templates/app/apache.conf.example new file mode 100644 index 0000000..2bda3e9 --- /dev/null +++ b/templates/app/apache.conf.example @@ -0,0 +1,55 @@ +# Example Apache virtual host for a PhpServicePlatform project. +# Copy to your Apache sites (e.g. /etc/apache2/sites-available/{{PROJECT_NAME}}.conf), +# adjust ServerName / paths / PHP handler, then: a2ensite … && systemctl reload apache2 +# Per-directory rules also ship as app/public/.htaccess (used when AllowOverride is on). +# +# Requires: mod_rewrite, mod_headers (a2enmod rewrite headers) + + + ServerName example.com + + # DocumentRoot is app/public ONLY — never the project root (keeps .env, + # config, src, vendor OUT of the web-served tree). + DocumentRoot /var/www/{{PROJECT_NAME}}/app/public + + + # Front-controller rewrite + hardening live in the shipped .htaccess. + # Set AllowOverride All to honour it, or inline those rules here and + # keep AllowOverride None for a small performance win. + AllowOverride All + Require all granted + Options -Indexes +FollowSymLinks + + + # Deny all dotfiles (.env, .git, .htaccess, …) anywhere under the vhost. + + Require all denied + + + # Only index.php may be executed as PHP (defence against dropped scripts). + + Require all denied + + + Require all granted + + + # Environment for the app (match your deployment). + SetEnv APP_ENV production + # SetEnv HKM_USERDATA_DIR /var/lib/hkm + + # Baseline security headers (SecurityFilters plugin also sets these at runtime). + + Header always unset X-Powered-By + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "SAMEORIGIN" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + ServerTokens Prod + ServerSignature Off + + LimitRequestBody 26214400 + + ErrorLog ${APACHE_LOG_DIR}/{{PROJECT_NAME}}-error.log + CustomLog ${APACHE_LOG_DIR}/{{PROJECT_NAME}}-access.log combined + diff --git a/tools/src/commands/new.zig b/tools/src/commands/new.zig index b85629b..a4a1659 100644 --- a/tools/src/commands/new.zig +++ b/tools/src/commands/new.zig @@ -57,6 +57,7 @@ const templates = [_]Template{ .{ .dest = "app/public/index.php", .src = "app/public/index.php" }, .{ .dest = "app/public/.htaccess", .src = "app/public/.htaccess" }, .{ .dest = "app/nginx.conf.example", .src = "app/nginx.conf.example" }, + .{ .dest = "app/apache.conf.example", .src = "app/apache.conf.example" }, .{ .dest = "app/swoole/index.php", .src = "app/swoole/index.php" }, .{ .dest = "app/cli/run.php", .src = "app/cli/run.php" }, .{ .dest = "app/worker/run.php", .src = "app/worker/run.php" }, From 98cd91b135d056da83453ffaa97d31f96337a74a Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 8 Jul 2026 03:17:46 +0300 Subject: [PATCH 014/140] chore(release): v1.0.5 Adds the Apache virtual-host sample to project scaffolding (alongside nginx). --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a80f098..6bd4c06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.5] - 2026-07-08 + ### Added - New projects also ship a full Apache virtual-host sample (`app/apache.conf.example`) alongside the nginx one — DocumentRoot pinned to @@ -115,7 +117,8 @@ macOS, and Windows, built and published automatically from a `v*` tag. (`phpunit.xml` is gitignored). - Windows cross-compilation: guarded POSIX-only raw-mode TTY code. -[Unreleased]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.4...HEAD +[Unreleased]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.5...HEAD +[1.0.5]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.4...v1.0.5 [1.0.4]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.3...v1.0.4 [1.0.3]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.1...v1.0.2 From badf5b1d9f9856363a9c7cf0797bfa4219ca405a Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 9 Jul 2026 01:57:43 +0300 Subject: [PATCH 015/140] docs: rewrite README for the native hkm CLI; fix broken doc links - README now documents native install (.deb/.tar.gz/.zip), the hkm command set, HKM_* env vars, requirements, dev/build flow, and security defaults. - Remove links to the removed docs/ai-context files from the Auth plugin README. --- README.md | 141 ++++++++++++++++++++++++++++++++--------- plugins/Auth/README.md | 5 +- 2 files changed, 112 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 67b906c..4fedb74 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,121 @@ -# php-service-platform +# AlfacodeTeam PhpServicePlatform -Documentation has been organized under [docs/README.md](docs/README.md). +A modular **PHP 8.4+** backend framework built on the **Gated Demand Architecture +(GDA)** pattern — security runs before any module loads, and only the modules a +request actually needs are wired in. The kernel is codenamed **Sentinel**. -Native global installation (Linux/macOS/Windows): +It ships as a **native cross-platform CLI** (`hkm`) built with Zig, so end users +install and upgrade it like a Go/Rust binary — no Composer required to get started. -1. Create a release tag (CI builds installers): - - `git tag v1.0.0 && git push origin v1.0.0` -2. Install from release artifacts: - - Linux: `psp-kernel__amd64.deb` - - Windows: `psp-kernel--windows-x86_64.zip` - - macOS: `psp-kernel--macos-universal.tar.gz` -3. Scaffold and run a project anywhere: - - `psp new /absolute/path/to/my-project --project=admin` - - `php /absolute/path/to/my-project/app/cli/run.php list` +--- -Notes: +## Install (Linux / macOS / Windows) -- The native launcher reads kernel location from `PSP_KERNEL_HOME` or `PSP_CLI_PATH`. -- Optional override for generated project autoload: `PSP_GLOBAL_AUTOLOAD=/path/to/vendor/autoload.php`. -- Full packaging and install instructions: [packaging/README.md](packaging/README.md). +Download the latest release from +[Releases](https://github.com/AlfaCode-Team/php-service-platform/releases/latest): -Composer-based global install remains supported for development: +**Linux (Debian/Ubuntu/Kali):** +```bash +sudo apt install ./hkm-kernel__amd64.deb +hkm doctor # verify PHP + extensions +``` -- `composer global require alfacode-team/php-service-platform` +**macOS:** extract `hkm-kernel--macos-universal.tar.gz`, then run +`HKM.app/Contents/Resources/opt/hkm-kernel/install.sh`. -Native system installers (no Composer required for end users): +**Windows:** extract `hkm-kernel--windows-x86_64.zip`, run +`hkm-kernel\install.bat`, add the folder to `PATH`. -- Debian/Kali apt package scaffolding: `packaging/apt/` -- Windows `.exe` bundle scaffolding: `packaging/windows/` -- macOS `.app` bundle scaffolding: `packaging/macos/` -- Zig launcher/config utility: `tools/psp-launcher-zig/` +The launcher **self-locates** the kernel — no environment variables required on a +standard install. Dependencies are resolved with Composer on the target at +install time (the runtime matches your exact PHP). -Key locations: +### Requirements (verified by `hkm doctor`) +- PHP **≥ 8.4.1** +- Extensions: `json, mbstring, ctype, tokenizer, filter, pdo, openssl, curl, fileinfo` +- At least one PDO driver (`mysql` / `pgsql` / `sqlite` / `sqlsrv`) +- Optional: `redis`, `swoole`/`openswoole`, `gd`, `intl` -- Commands reports: [docs/reports/commands](docs/reports/commands) -- Database reports: [docs/reports/database](docs/reports/database) -- Enterprise reports: [docs/reports/enterprise](docs/reports/enterprise) -- Infrastructure reports: [docs/reports/infrastructure](docs/reports/infrastructure) -- Migrations reports: [docs/reports/migrations](docs/reports/migrations) -- Deployment guides: [docs/guides](docs/guides) -- AI context: [docs/ai-context](docs/ai-context) +--- + +## The `hkm` CLI + +| Command | Purpose | +|---|---| +| `hkm new [--project=]` | Scaffold a new project (secure defaults: `.env` chmod 600, Apache **+** nginx configs) | +| `hkm run [path\|name]` | Run a project locally (PHP dev server) | +| `hkm cli [command]` | Run a project's console interactively | +| `hkm worker [args]` | Run a project's queue worker | +| `hkm list` | List registered projects | +| `hkm plugins [path\|name]` | Analyse a project's enabled plugins/modules | +| `hkm ui [sync\|list\|link\|clean]` | Federate enabled plugins' UIs into the frontend | +| `hkm doctor` | Diagnose PHP, extensions, and the resolved kernel path | +| `hkm-config` | Set up / repair the full environment (kernel + userdata) | +| `hkm upgrade [--check]` | Check for and install a newer release automatically | +| `hkm version` / `--version` / `-v` | Show the Sentinel banner + version | + +### Environment (all auto-detected — override only for non-standard layouts) +| Variable | Meaning | +|---|---| +| `HKM_KERNEL_HOME` | Kernel root (holds `composer.json`, `vendor/`, `projects/`, `templates/`) | +| `HKM_USERDATA_DIR` | Persistent registry dir (`projects.json` + `platform.json`) that **survives updates** | +| `HKM_PHP_BIN` | Override the `php` binary | +| `HKM_CLI_PATH` / `HKM_GLOBAL_AUTOLOAD` | Override the PHP CLI script / kernel autoload | + +Run `hkm-config` once and it pins `HKM_KERNEL_HOME` and provisions a persistent +`HKM_USERDATA_DIR` (migrating any existing registry) in +`~/.config/hkm/config.env`. + +--- + +## Development (from source) + +```bash +git clone --recurse-submodules git@github.com:AlfaCode-Team/php-service-platform.git +cd php-service-platform +composer install +vendor/bin/phpunit # run the test suite + +# Build the native launcher (needs Zig — see tools/.zig-version): +cd tools && zig build --release=small # → ../bin/hkm + ../bin/hkm-config +``` + +### Building release bundles +```bash +VERSION=1.2.3 ./tools/bundle.sh all # .deb + macOS .app + Windows .zip → dist/ +# MODULES=git ./tools/bundle.sh linux # fetch path-repo modules from pinned commits +``` +Releases are cut by pushing a `v*` tag — CI runs the test suite first, then builds +all three OS bundles on Linux and publishes them automatically. + +--- + +## Architecture at a glance + +- **Kernel (Sentinel)** — boot pipeline, SecurityGateway, on-demand loading, + scoped DI containers, HTTP/CLI/Worker pipelines, ports. +- **Plugins** (`plugins/`, `Plugins\` namespace) — bounded business/infrastructure + modules (Auth, OAuth2, Tenancy, User, Storage, Session, Cookie, View, …). +- **Projects** (`projects/`) — per-project wiring; the runtime resolves an + incoming host to a project via `DomainResolver`. + +See the per-plugin `README.md` files (e.g. [Auth](plugins/Auth/README.md), +[Tenancy](plugins/Tenancy/README.md), [User](plugins/User/README.md)) and the +[CHANGELOG](CHANGELOG.md). + +--- + +## Security defaults + +Scaffolded projects are hardened by default: `.env` is `chmod 600`, debug output +is force-disabled when `APP_ENV=production`, and every new project ships web-server +configs (`app/public/.htaccess`, `app/apache.conf.example`, `app/nginx.conf.example`) +that pin the docroot to `app/public`, deny dotfiles, and add baseline security +headers. Keep secrets (`APP_KEY`, JWT signing keys, DB credentials) out of the CLI +config and, in production, behind a `SECRETS_PROVIDER`. + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/plugins/Auth/README.md b/plugins/Auth/README.md index a176203..0fb9718 100644 --- a/plugins/Auth/README.md +++ b/plugins/Auth/README.md @@ -6,7 +6,7 @@ It does **not** do authorization policy (that's your service layer) and it is **not** the multi-tenant control plane (that's `Plugins\Tenancy`). > 📄 A full typeset walkthrough ships alongside this file: [`AUTH_GUIDE.pdf`](AUTH_GUIDE.pdf). -> Deep-dive reference: [`docs/ai-context/25_AUTH.md`](../../docs/ai-context/25_AUTH.md). +> Deep-dive reference: see the Auth design notes in this README below. ## The one split to remember @@ -308,5 +308,4 @@ unserialize a recaller · confuse `personal_access_tokens` (user keys) with --- -*OAuth 2.1 / OIDC authorization-server flows live in `Plugins\OAuth2` -([`docs/ai-context/26_OAUTH2.md`](../../docs/ai-context/26_OAUTH2.md)).* +*OAuth 2.1 / OIDC authorization-server flows live in the `Plugins\OAuth2` plugin.* From a15ae267f745e4050d235c0eb71dbf55a1ba5781 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 9 Jul 2026 06:42:03 +0300 Subject: [PATCH 016/140] feat(routes,cli): project routePolicy.disable + hkm --dev environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route policy — the third route verb (add/override/DISABLE): - Kernel::withRoutePolicy() + proj.json "routePolicy": {"disable": []} let a project veto plugin routes without forking the plugin. Specs are either "METHOD /path" (one route) or a module domain (all of a plugin's routes). - CompileRouteManifestStage applies the policy to plugin routes AFTER they compile and BEFORE project routes, so a disabled key can be re-declared by the project. An unmatched spec fails the boot (anti-typo guard). - EntryHelpers::projectRoutePolicy() reads the proj.json block. hkm dev environment for contributors: - `hkm --dev` pins one invocation to the development kernel (HKM_DEV_HOME from config.env, or walk-up self-location from a repo-built launcher). Exports HKM_KERNEL_HOME + HKM_CLI_PATH for the child only; fails loudly when no dev kernel is found. - hkm-config set-dev-home (validated) + help/README documentation. Templates: scaffolded proj.json ships the routePolicy stub, bootstrap wires withRoutePolicy(), project README documents the three route verbs. --- projects/Bootstrap/EntryHelpers.php | 37 ++++++++++ src/Kernel/Boot/BootPipeline.php | 6 +- .../Boot/Stages/CompileRouteManifestStage.php | 68 +++++++++++++++++++ src/Kernel/Kernel.php | 36 ++++++++++ templates/README.md | 39 ++++++++--- templates/app/bootstrap/app.php | 7 ++ templates/proj.json | 1 + tools/README.md | 42 ++++++++++++ tools/src/config.zig | 16 +++++ tools/src/lib/kernel.zig | 26 +++++++ tools/src/main.zig | 56 ++++++++++++++- 11 files changed, 322 insertions(+), 12 deletions(-) diff --git a/projects/Bootstrap/EntryHelpers.php b/projects/Bootstrap/EntryHelpers.php index 99dae89..cf28f46 100644 --- a/projects/Bootstrap/EntryHelpers.php +++ b/projects/Bootstrap/EntryHelpers.php @@ -134,6 +134,43 @@ public static function projectRoutes(string $projectPath): array return $routes; } + /** + * Read the project's route-disable policy from /proj.json under + * "routePolicy": { "disable": [ ... ] } (a bare "disable": [...] top-level key + * is also accepted). Each entry is a "METHOD /path" spec or a module domain, + * passed straight to Kernel::withRoutePolicy(); the route-manifest compiler + * validates them at boot (an unmatched spec fails with a descriptive error). + * Non-string / malformed entries are dropped here. + * + * @return list + */ + public static function projectRoutePolicy(string $projectPath): array + { + $file = rtrim($projectPath, '/') . '/proj.json'; + if (!is_file($file)) { + return []; + } + + $data = json_decode((string) file_get_contents($file), true); + if (!is_array($data)) { + return []; + } + + $disable = $data['routePolicy']['disable'] ?? $data['disable'] ?? null; + if (!is_array($disable)) { + return []; + } + + $specs = []; + foreach ($disable as $spec) { + if (is_string($spec) && trim($spec) !== '') { + $specs[] = trim($spec); + } + } + + return $specs; + } + private static function sanitiseProject(string $project): string { $project = trim($project); diff --git a/src/Kernel/Boot/BootPipeline.php b/src/Kernel/Boot/BootPipeline.php index 6d55662..c4401d5 100644 --- a/src/Kernel/Boot/BootPipeline.php +++ b/src/Kernel/Boot/BootPipeline.php @@ -38,12 +38,16 @@ final class BootPipeline * @param list $projectRoutes * Project-layer routes (declared via Kernel::withRoutes), compiled into the * route manifest under the synthetic '__project__' scope with no module graph. + * @param list $disabledRoutes + * Project route-disable policy (Kernel::withRoutePolicy). "METHOD /path" or a + * module domain; applied to plugin routes before project routes are compiled. */ public function __construct( private readonly array $moduleClasses, private readonly CoreContainer $core, array $securityLayers = [], array $projectRoutes = [], + array $disabledRoutes = [], ) { // Single reader shared across every manifest-reading stage: each module.json // (the single source of truth) is read + JSON-decoded ONCE and cached, instead @@ -56,7 +60,7 @@ public function __construct( new DetectConflictsStage($moduleClasses, reader: $reader), // 2. no two modules share solves() new DetectCyclesStage($moduleClasses, reader: $reader), // 3. no circular requires[] chains new CompileServiceManifestStage($moduleClasses, projectRoutes: $projectRoutes, reader: $reader), // 4. dep graph → service-manifest.php - new CompileRouteManifestStage($moduleClasses, projectRoutes: $projectRoutes, reader: $reader), // 5. routes[] → route-manifest.php + new CompileRouteManifestStage($moduleClasses, projectRoutes: $projectRoutes, disabledRoutes: $disabledRoutes, reader: $reader), // 5. routes[] → route-manifest.php new CompileViewManifestStage($moduleClasses, reader: $reader), // 6. views[] → view-manifest.php (project-first cascade) new CompileJobManifestStage($moduleClasses, reader: $reader), // 7. jobs[] → job-manifest.php new CompileCommandManifestStage($moduleClasses, reader: $reader), // 8. commands[] → command-manifest.php diff --git a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php index 13bfdaf..bac8245 100644 --- a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php +++ b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php @@ -13,10 +13,18 @@ final class CompileRouteManifestStage implements BootStageContract /** * @param list $moduleClasses * @param list $projectRoutes + * @param list $disabledRoutes + * Project route policy (proj.json "routePolicy.disable" / Kernel::withRoutePolicy). + * Each entry is EITHER a "METHOD /path" spec (drops that one plugin route) OR a + * bare module domain (drops EVERY plugin route that module solves()). Applied + * AFTER plugin routes and BEFORE project routes, so a project can veto a plugin + * route and then optionally re-declare its own on the freed key. A spec that + * matches nothing fails the boot — no silent typos. */ public function __construct( private readonly array $moduleClasses, private readonly array $projectRoutes = [], + private readonly array $disabledRoutes = [], private readonly ManifestReader $reader = new ManifestReader(), ) {} @@ -73,6 +81,13 @@ public function run(): void } } + // PASS 2a.5 — apply the project's route DISABLE policy. Runs on plugin + // routes only (project routes are compiled below and are the project's own + // to add/remove). Dropping BEFORE project routes frees the "METHOD path" + // key so a project may disable a plugin route AND declare its own on it + // without a duplicate-route boot failure. + $routes = $this->applyDisablePolicy($routes); + // PASS 2b — project-layer routes (Kernel::withRoutes / proj.json), not in // any module.json. They carry no module and resolve under the synthetic // PROJECT_SCOPE, whose dependency graph is empty — so route-level @@ -137,6 +152,59 @@ private function validateRequires(array $requires, array $knownDomains, string $ return $requires; } + /** + * Drop plugin routes the project explicitly disabled, then verify every + * disable spec matched at least one route — an unmatched spec is a typo or a + * stale reference and fails the boot with a descriptive message (mirrors the + * unknown-requires-domain guard). Two spec forms, distinguished by shape: + * - "METHOD /path" → contains whitespace AND a "/path" part → exact route key + * - "domain" → anything else → every plugin route whose solves() matches + * + * @param array $routes + * @return array> + */ + private function applyDisablePolicy(array $routes): array + { + foreach ($this->disabledRoutes as $spec) { + $spec = trim($spec); + if ($spec === '') { + continue; + } + + $isRouteKey = str_contains($spec, ' ') && str_contains($spec, '/'); + $matched = 0; + + if ($isRouteKey) { + // Normalize "get /register" → "GET /register". + [$method, $path] = preg_split('/\s+/', $spec, 2) ?: [$spec, '']; + $key = strtoupper($method) . ' ' . $path; + if (isset($routes[$key])) { + unset($routes[$key]); + $matched = 1; + } + } else { + // Domain form — drop every plugin route that module solves(). + foreach ($routes as $key => $route) { + if (($route['solves'] ?? null) === $spec) { + unset($routes[$key]); + $matched++; + } + } + } + + if ($matched === 0) { + throw new BootException( + "routePolicy.disable [{$spec}] matched no plugin route. " + . 'Use "METHOD /path" for a single route or a module domain to ' + . 'disable all of its routes — check the spelling and that the ' + . 'owning plugin is listed in withModules()/withEssentialModules().' + ); + } + } + + return $routes; + } + /** * Normalize a route's declared filters to a clean list of string specs. * Accepts a single string ("auth") or a list (["auth", "throttle:60"]). diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index 16b22a6..9b8c062 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -43,6 +43,8 @@ final class Kernel private array $essentialModules = []; /** @var list */ private array $projectRoutes = []; + /** @var array disable-spec => spec (de-duplicated, insertion order) */ + private array $disabledRoutes = []; private ?ErrorPipeline $errorPipeline = null; private ?\Closure $errorPipelineFun = null; private ?string $basePath = null; @@ -178,6 +180,39 @@ public function withRoutes(array $routes): self return $this; } + /** + * Declare the project's ROUTE-DISABLE policy — plugin routes the project + * chooses NOT to expose. A plugin OWNS and declares its routes, but the + * project deploying it stays the final authority: it can veto specific + * plugin routes here without forking the plugin. + * + * ->withRoutePolicy([ + * 'GET /register', // disable one plugin route (method + path) + * 'oauth.server', // disable EVERY route the oauth.server module solves() + * ]) + * + * Each spec is EITHER a "METHOD /path" string (one exact plugin route) or a + * bare module domain (all of that module's routes). Specs are applied at boot + * to plugin routes only, BEFORE project routes compile — so a project can + * disable a plugin route and then declare its own on the freed key. A spec + * matching nothing FAILS the build (no silent typos). Project routes declared + * via withRoutes() are the project's own and are not affected. + * + * Appends + de-duplicates; a base builder's disables carry into child projects. + * + * @param list $disable + */ + public function withRoutePolicy(array $disable): self + { + foreach ($disable as $spec) { + $spec = trim((string) $spec); + if ($spec !== '') { + $this->disabledRoutes[$spec] = $spec; + } + } + return $this; + } + /** * Build and validate the kernel. Fails fast on any misconfiguration. * @@ -214,6 +249,7 @@ public function build(): self $this->core, $this->securityLayers, array_values($this->projectRoutes), + array_values($this->disabledRoutes), ))->run(); $this->built = true; diff --git a/templates/README.md b/templates/README.md index 7e161da..ed2d981 100644 --- a/templates/README.md +++ b/templates/README.md @@ -15,13 +15,32 @@ php -S localhost:8000 -t app/public ## Layout -| Path | Role | -|-----------------|--------------------------------------------------| -| `app/bootstrap` | Kernel autoload + project bootstrap | -| `app/public` | HTTP entry (`index.php`) | -| `app/cli` | CLI entry (`run.php`) | -| `src/` | Project-only code (namespace `{{STUDLY}}\`) | -| `config/` | Project configuration | -| `database/` | LetMigrate migrations / seeders / factories | -| `resources/` | Views | -| `proj.json` | Project manifest (routes, domains, views) | +| Path | Role | +|-----------------|---------------------------------------------------------| +| `app/bootstrap` | Kernel autoload + project bootstrap | +| `app/public` | HTTP entry (`index.php`) | +| `app/cli` | CLI entry (`run.php`) | +| `src/` | Project-only code (namespace `{{STUDLY}}\`) | +| `config/` | Project configuration | +| `database/` | LetMigrate migrations / seeders / factories | +| `resources/` | Views | +| `proj.json` | Project manifest (routes, domains, views, routePolicy) | + +## Routes — three verbs over plugin routes + +Plugins declare their own routes (in each plugin's `module.json`); this project +stays the final authority via `proj.json`: + +- **Add** — declare project routes in `routes[]` (handler = full class path, + `Controller@method`). Optional per-route `"requires": ["domain", …]` pulls + specific plugins into that route; `"filters": ["auth", …]` gates it. +- **Override** — a project route with the same `METHOD path` as a plugin route + replaces it. +- **Disable** — list plugin routes the project will not expose in + `routePolicy.disable[]`: either `"GET /register"` (one route) or a module + domain like `"oauth.server"` (all of that plugin's routes). A spec matching + nothing fails the boot — typos never pass silently. + +```jsonc +"routePolicy": { "disable": ["GET /register", "oauth.server"] } +``` diff --git a/templates/app/bootstrap/app.php b/templates/app/bootstrap/app.php index 559a30f..695bf57 100644 --- a/templates/app/bootstrap/app.php +++ b/templates/app/bootstrap/app.php @@ -234,6 +234,13 @@ // Keep these controllers thin; real domain logic lives in plugins. ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Project ROUTE POLICY declared in proj.json ("routePolicy": {"disable": []}). + // A plugin OWNS its routes, but the project is the final authority: it can + // veto specific plugin routes ("METHOD /path") or a whole plugin's routes (a + // module domain) without forking the plugin. Applied to plugin routes before + // project routes compile — an unmatched spec fails the boot. + ->withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot)) + // Security layers run BEFORE any module loads — a denied request costs zero // module work. CsrfTokenLayer here is a stateless, HMAC-signed token // (WordPress-nonce style): the token is signed with APP_KEY and bound to the diff --git a/templates/proj.json b/templates/proj.json index 4cdbe1e..c8d5736 100644 --- a/templates/proj.json +++ b/templates/proj.json @@ -4,6 +4,7 @@ "domains": {{DOMAINS_JSON}}, "features": [], "views": "resources", + "routePolicy": { "disable": [] }, "routes": [ { "method": "GET", "path": "/", "handler": "{{STUDLY}}\\Infrastructure\\Http\\HomeController@index" }, { "method": "GET", "path": "/ping", "handler": "{{STUDLY}}\\Infrastructure\\Http\\HomeController@ping" } diff --git a/tools/README.md b/tools/README.md index 8d97417..df4bda0 100644 --- a/tools/README.md +++ b/tools/README.md @@ -48,6 +48,48 @@ zig build --release=small # ~230KB, smallest (no safety checks) Binaries land in `zig-out/bin/{hkm,hkm-config}`. +## Dev environment — stable install + dev checkout side by side (`--dev`) + +A contributor typically has TWO kernels on the machine: + +| Kernel | Where | Used when | +| --- | --- | --- | +| **Stable** (installed) | `/opt/hkm-kernel` (from the `.deb` / release bundle) | everyday `hkm …` — real projects keep working | +| **Dev** (checkout) | the cloned monorepo, e.g. `~/Documents/HKMCODE` | `hkm --dev` — testing framework changes | + +`hkm` always targets the stable install (via `HKM_KERNEL_HOME` in +`~/.config/hkm/config.env`). Appending `--dev` to ANY command pins that ONE +invocation to the dev checkout instead — it exports `HKM_KERNEL_HOME` + +`HKM_CLI_PATH` for the child process only, so nothing persistent changes and +the flag never leaks into downstream arg parsing. + +### One-time contributor setup + +```sh +git clone ~/Documents/HKMCODE +cd ~/Documents/HKMCODE && composer install # dev kernel needs its vendor/ +hkm-config set-dev-home ~/Documents/HKMCODE # register the checkout (validated) +``` + +### Daily use + +```sh +hkm run my-shop # stable kernel — production behaviour +hkm run my-shop --dev # SAME project, but on your patched dev kernel +hkm doctor --dev # confirm which kernel --dev resolves to +``` + +`--dev` resolves the dev kernel in this order: + +1. **`HKM_DEV_HOME`** (set once via `hkm-config set-dev-home`) — works from + anywhere, including the installed `/usr/bin/hkm`. +2. **Self-location** — when you run a repo-built launcher + (`tools/zig-out/bin/hkm`), it walks UP from its own executable to the nearest + ancestor holding `composer.json`. No config needed inside the checkout. + +If neither resolves, `--dev` fails loudly (it never silently falls back to the +stable kernel — a "dev" run must never accidentally test production code). + ## Adding a command 1. Create `src/commands/.zig` with the standard `run(...)` signature. diff --git a/tools/src/config.zig b/tools/src/config.zig index 79d9035..b977617 100644 --- a/tools/src/config.zig +++ b/tools/src/config.zig @@ -6,6 +6,7 @@ //! hkm-config print # show the config file path + contents //! hkm-config set-kernel-home

# pin HKM_KERNEL_HOME //! hkm-config set-autoload

# pin HKM_GLOBAL_AUTOLOAD (vendor/autoload.php) +//! hkm-config set-dev-home

# pin HKM_DEV_HOME (dev checkout used by --dev) //! //! "check" resolves the kernel (env → relative to this binary → /opt/hkm-kernel) //! and, if the config file is missing or stale, writes HKM_KERNEL_HOME for you. @@ -61,6 +62,20 @@ pub fn main(init: std.process.Init.Minimal) !void { prompt.ok("HKM_GLOBAL_AUTOLOAD saved."); return; } + if (std.mem.eql(u8, action, "set-dev-home")) { + if (args.len < 3) return usage(); + // The DEV kernel: a contributor's monorepo checkout, used only when a + // command is invoked with --dev. Validate it looks like a kernel root + // so a typo fails here rather than at first --dev use. + const p = util.trimSlash(args[2]); + if (!kernel.isKernelDir(io, p)) { + prompt.err("that path is not a kernel checkout (no composer.json)."); + std.process.exit(1); + } + try userconfig.set(allocator, io, &env, "HKM_DEV_HOME", p); + prompt.ok("HKM_DEV_HOME saved. Use `hkm --dev` to target it."); + return; + } if (std.mem.eql(u8, action, "check") or std.mem.eql(u8, action, "configure")) { std.process.exit(try runCheck(allocator, io, &env)); } @@ -74,6 +89,7 @@ fn usage() void { prompt.item("hkm-config print", "show the config file path + contents"); prompt.item("hkm-config set-kernel-home

", "pin the kernel root"); prompt.item("hkm-config set-autoload

", "pin vendor/autoload.php"); + prompt.item("hkm-config set-dev-home

", "pin the development kernel checkout used by --dev"); } fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { diff --git a/tools/src/lib/kernel.zig b/tools/src/lib/kernel.zig index 2a38e70..47242bc 100644 --- a/tools/src/lib/kernel.zig +++ b/tools/src/lib/kernel.zig @@ -65,6 +65,12 @@ pub fn findCliPath(allocator: std.mem.Allocator, io: Io, env: *EnvMap) ![]const return (try resolve(allocator, io, env)).path; } +/// Public probe: is `dir` a kernel root (holds composer.json)? Used to validate +/// an explicit HKM_DEV_HOME before pinning the invocation to it. +pub fn isKernelDir(io: Io, dir: []const u8) bool { + return isKernelRoot(io, dir); +} + /// A directory is a kernel root if it holds composer.json (true for both the /// dev monorepo and an installed /opt/hkm-kernel). fn isKernelRoot(io: Io, dir: []const u8) bool { @@ -103,6 +109,26 @@ pub fn resolveHome(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const return null; } +/// Resolve the DEVELOPMENT kernel root by walking UP the directory tree from +/// this launcher's own executable until a kernel root (a dir with composer.json) +/// is found. Unlike resolveHome's self-location — which only checks fixed bundle +/// layouts one level up — this handles a launcher run from anywhere inside the +/// monorepo, e.g. `tools/zig-out/bin/hkm` (three levels below the repo root). +/// Returns null when no ancestor is a kernel root. +pub fn resolveDevHome(allocator: std.mem.Allocator, io: Io) !?[]const u8 { + const dir = std.process.executableDirPathAlloc(io, allocator) catch return null; + var cur: []const u8 = dir; + // Bound the climb so we never loop forever on a malformed path. + var depth: usize = 0; + while (depth < 32) : (depth += 1) { + if (isKernelRoot(io, cur)) return cur; + const parent = std.fs.path.dirname(cur) orelse return null; + if (std.mem.eql(u8, parent, cur)) return null; // reached filesystem root + cur = parent; + } + return null; +} + pub fn sourceLabel(s: Source) []const u8 { return switch (s) { .cli_path_env => "HKM_CLI_PATH override", diff --git a/tools/src/main.zig b/tools/src/main.zig index f13f414..48ad735 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -9,6 +9,7 @@ const cli_cmd = @import("commands/cli.zig"); const doctor_cmd = @import("commands/doctor.zig"); const upgrade_cmd = @import("commands/upgrade.zig"); const kernel = @import("lib/kernel.zig"); +const util = @import("lib/util.zig"); const userconfig = @import("lib/userconfig.zig"); const banner = @import("lib/banner.zig"); const prompt = @import("lib/prompt.zig"); @@ -29,6 +30,7 @@ fn printHelp() void { prompt.item("hkm doctor", "diagnose the local environment"); prompt.item("hkm version", "show the Sentinel banner + version (also --version, -v)"); prompt.item("hkm help", "show this help"); + prompt.item("hkm --dev", "use the development kernel (this monorepo) instead of the installed stable copy"); prompt.blank(); prompt.section("Environment"); @@ -39,6 +41,7 @@ fn printHelp() void { prompt.item("HKM_USERDATA_DIR", "persistent registry dir (projects.json + platform.json); survives updates"); prompt.item("PSP_PROJECTS_DIR", "dir holding the kernel projects.json registry"); prompt.item("HKM_KERNEL_HOME", "kernel root (registry at /projects/projects.json)"); + prompt.item("HKM_DEV_HOME", "development kernel checkout used by --dev (set once via hkm-config)"); prompt.outro("Run 'hkm --help' for command details"); } @@ -76,7 +79,58 @@ pub fn main(init: std.process.Init.Minimal) !void { // `hkm-config` take effect. Real environment variables always win. userconfig.load(allocator, io, &env_map); - const args = try init.args.toSlice(allocator); + const raw_args = try init.args.toSlice(allocator); + + // `--dev` (anywhere in the args) pins this invocation to the DEVELOPMENT + // kernel — the monorepo this launcher was built inside — instead of the + // installed stable copy under /opt. Useful when running a freshly-built + // tools/zig-out/bin/hkm from within the repo. We resolve the dev root by + // climbing to the nearest ancestor holding composer.json, export it as + // HKM_KERNEL_HOME + HKM_CLI_PATH so every command AND the passthrough use + // it, then strip the flag so downstream arg parsing never sees it. + var dev_mode = false; + var args_list: std.ArrayList([]const u8) = .empty; + defer args_list.deinit(allocator); + for (raw_args) |a| { + if (std.mem.eql(u8, a, "--dev")) { + dev_mode = true; + continue; + } + try args_list.append(allocator, a); + } + const args = args_list.items; + + if (dev_mode) { + // Resolve the dev kernel in two ways, in order: + // 1. HKM_DEV_HOME — an explicit checkout path from config.env. This is + // what lets the INSTALLED hkm (/usr/bin/hkm) target a contributor's + // monorepo checkout anywhere on disk. + // 2. Walk up from the launcher — works when running the repo-built + // tools/zig-out/bin/hkm from inside the checkout, no config needed. + var dev_home: ?[]const u8 = null; + if (env_map.get("HKM_DEV_HOME")) |h| { + if (h.len > 0) { + const t = util.trimSlash(h); + if (kernel.isKernelDir(io, t)) { + dev_home = try allocator.dupe(u8, t); + } else { + prompt.err(try std.fmt.allocPrint(allocator, "HKM_DEV_HOME points to {s} but that is not a kernel checkout (no composer.json).", .{t})); + std.process.exit(1); + } + } + } + if (dev_home == null) dev_home = try kernel.resolveDevHome(allocator, io); + + if (dev_home) |home| { + try env_map.put("HKM_KERNEL_HOME", home); + const cli = try std.fs.path.join(allocator, &.{ home, "bin", "hkm" }); + try env_map.put("HKM_CLI_PATH", cli); + prompt.muted(try std.fmt.allocPrint(allocator, "dev mode: using kernel at {s}", .{home})); + } else { + prompt.err("--dev: no development kernel found. Set HKM_DEV_HOME to your checkout, or run the repo-built tools/zig-out/bin/hkm from inside it."); + std.process.exit(1); + } + } if (args.len <= 1) { printHelp(); From 689c983940fc27144bee0416d94e3308c4ccd153 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 9 Jul 2026 06:42:16 +0300 Subject: [PATCH 017/140] chore(release): v1.0.6 Project routePolicy.disable (veto plugin routes without forking) and the hkm --dev contributor environment (stable install + dev checkout side by side). --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd4c06..a7debaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.6] - 2026-07-09 + +### Added +- **Project route policy — disable plugin routes without forking.** A plugin + still owns and declares its routes, but the deploying project is now the + final authority: `proj.json` gains `"routePolicy": { "disable": [...] }` + (wired via the new `Kernel::withRoutePolicy()` + + `EntryHelpers::projectRoutePolicy()`). Each spec is either `"METHOD /path"` + (one plugin route) or a module domain like `"oauth.server"` (every route that + module solves). Applied at boot to plugin routes BEFORE project routes + compile, so a project can veto a plugin route and re-declare its own on the + freed key. A spec matching nothing fails the build with a descriptive error — + typos never pass silently. +- **`hkm --dev`** — pin a single invocation to the DEVELOPMENT kernel + instead of the installed stable copy. Resolves via the new `HKM_DEV_HOME` + config key (set once with `hkm-config set-dev-home `, validated), + or by walking up from a repo-built launcher to the nearest `composer.json`. + Exports `HKM_KERNEL_HOME` + `HKM_CLI_PATH` for the child process only — + nothing persistent changes, and the flag is stripped before downstream arg + parsing. Fails loudly when no dev kernel is found (never silently falls back + to stable). +- `hkm-config set-dev-home ` subcommand + `HKM_DEV_HOME` in `hkm help`; + contributor "Dev environment" guide in `tools/README.md`. +- New-project templates updated: scaffolded `proj.json` ships a + `routePolicy.disable` stub, the bootstrap wires `withRoutePolicy(...)`, and + the project README documents the three route verbs (add / override / disable). + ## [1.0.5] - 2026-07-08 ### Added From 65be3c48da97fb505fd225156bae66e7960a8e43 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 9 Jul 2026 06:46:19 +0300 Subject: [PATCH 018/140] docs: document hkm --dev + HKM_DEV_HOME in README and CLI usage guide - hkm-cli-usage.md: HKM_DEV_HOME env var + "--dev" section (setup, resolution order, fail-loud semantics) - README.md: --dev command row + HKM_DEV_HOME in the environment table --- README.md | 2 ++ tools/docs/hkm-cli-usage.md | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/README.md b/README.md index 4fedb74..89762df 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,13 @@ install time (the runtime matches your exact PHP). | `hkm-config` | Set up / repair the full environment (kernel + userdata) | | `hkm upgrade [--check]` | Check for and install a newer release automatically | | `hkm version` / `--version` / `-v` | Show the Sentinel banner + version | +| `hkm --dev` | Run any command against the **development** kernel checkout instead of the installed one | ### Environment (all auto-detected — override only for non-standard layouts) | Variable | Meaning | |---|---| | `HKM_KERNEL_HOME` | Kernel root (holds `composer.json`, `vendor/`, `projects/`, `templates/`) | +| `HKM_DEV_HOME` | Development kernel checkout used by `--dev` (set once: `hkm-config set-dev-home `) | | `HKM_USERDATA_DIR` | Persistent registry dir (`projects.json` + `platform.json`) that **survives updates** | | `HKM_PHP_BIN` | Override the `php` binary | | `HKM_CLI_PATH` / `HKM_GLOBAL_AUTOLOAD` | Override the PHP CLI script / kernel autoload | diff --git a/tools/docs/hkm-cli-usage.md b/tools/docs/hkm-cli-usage.md index 04cf0bf..8f4b8f4 100644 --- a/tools/docs/hkm-cli-usage.md +++ b/tools/docs/hkm-cli-usage.md @@ -276,12 +276,32 @@ publish on the next enable. Migrations get a UTC timestamp prefix HKM_PHP_BIN override the php binary (default: php) HKM_CLI_PATH override the target php CLI script HKM_KERNEL_HOME kernel root (registry at /projects/projects.json) +HKM_DEV_HOME development kernel checkout used by --dev (hkm-config set-dev-home) HKM_GLOBAL_AUTOLOAD override the kernel vendor/autoload.php PSP_GLOBAL_AUTOLOAD explicit kernel autoload (exported to child PHP) PSP_PROJECTS_DIR dir holding the kernel projects.json registry HKM_TEMPLATES_DIR override the scaffolding templates directory ``` +## --dev — target the development kernel + +Every command accepts `--dev` (anywhere in the args; stripped before command +parsing). It pins that ONE invocation to the DEVELOPMENT kernel instead of the +installed stable copy — for contributors keeping both side by side: + +```bash +hkm-config set-dev-home ~/code/php-service-platform # one-time (validated) +hkm run my-shop # stable kernel (/opt/hkm-kernel) +hkm run my-shop --dev # SAME project on the dev checkout +hkm doctor --dev # confirm what --dev resolves to +``` + +Resolution order: `HKM_DEV_HOME` (works from the installed binary, anywhere) → +walk UP from a repo-built launcher to the nearest `composer.json`. It exports +`HKM_KERNEL_HOME` + `HKM_CLI_PATH` for the child process only — nothing +persistent changes. When no dev kernel is found, `--dev` fails loudly; it never +silently falls back to the stable kernel. + **Resolution order** - **Kernel plugins dir:** `HKM_KERNEL_HOME/plugins` → registry-root `/plugins` → From edc79a24a5519f7520816a1879e7ab5091f375f7 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 9 Jul 2026 06:50:52 +0300 Subject: [PATCH 019/140] ci: bump actions to v5 (checkout, upload/download-artifact) for Node 24 GitHub deprecated Node 20 on Actions runners; v4 actions were being forced onto Node 24 with warnings on every run. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5ead3b..fce0b71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: matrix: php: ["8.4"] # real floor: Symfony 8 + PHPUnit require PHP >= 8.4.1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: { submodules: recursive } # path-repo modules must be present - uses: shivammathur/setup-php@v2 with: @@ -48,7 +48,7 @@ jobs: name: Zig build (all targets) runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup Zig (self-hosted pinned toolchain) run: ./tools/ci/setup-zig.sh env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 064a05d..3aff70b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: name: Test (PHPUnit) runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: { submodules: recursive } # path-repo modules must be present - uses: shivammathur/setup-php@v2 with: @@ -41,7 +41,7 @@ jobs: needs: test runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: { submodules: recursive } - name: Setup Zig run: ./tools/ci/setup-zig.sh @@ -50,7 +50,7 @@ jobs: with: { php-version: "8.4", tools: composer } - name: Bundle (linux) run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh linux - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: { name: linux-deb, path: dist/*.deb } # ── Windows: .zip (x86_64, cross-compiled) ──────────────────────────────── @@ -59,7 +59,7 @@ jobs: needs: test runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: { submodules: recursive } - name: Setup Zig run: ./tools/ci/setup-zig.sh @@ -68,7 +68,7 @@ jobs: with: { php-version: "8.4", tools: composer } - name: Bundle (windows) run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh windows - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: { name: windows-zip, path: dist/*.zip } # ── macOS: universal .app tarball (arm64 + x86_64), built ON LINUX ───────── @@ -77,7 +77,7 @@ jobs: needs: test runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: { submodules: recursive } - name: Install llvm-lipo (for the universal Mach-O) run: sudo apt-get update && sudo apt-get install -y llvm @@ -88,7 +88,7 @@ jobs: with: { php-version: "8.4", tools: composer } - name: Bundle (macos) run: VERSION="${GITHUB_REF_NAME#v}" ./tools/bundle.sh macos - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: { name: macos-app, path: dist/*.tar.gz } # ── Publish GitHub Release with all artifacts ───────────────────────────── @@ -97,8 +97,8 @@ jobs: needs: [build-linux, build-windows, build-macos] runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 # need CHANGELOG.md at the tagged commit - - uses: actions/download-artifact@v4 + - uses: actions/checkout@v5 # need CHANGELOG.md at the tagged commit + - uses: actions/download-artifact@v5 with: { path: artifacts/ } - name: Extract CHANGELOG section for this version id: notes From d35a689afd9f40595336ffacdff7b2d635c87f21 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 01:14:23 +0300 Subject: [PATCH 020/140] =?UTF-8?q?feat(tools):=20hkm=20plugins=20upgrade?= =?UTF-8?q?=20=E2=80=94=20split-safe=20project=20upgrade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `hkm plugins upgrade [project]` command (aliases: reconcile, migrate) that upgrades a project after its plugins changed, in three idempotent phases: 1. Dependency healing — auto-enable providers a plugin newly requires. 2. Assets + migrations — publish new assets + run pending migrations (delegates to update; applied migrations skip by name). 3. Split reconciliation — when a migration moved to a new plugin (a split), transfer its manifest ownership to the new owner WITHOUT touching the database. The shared let_migrations row is keyed by filename and stays applied, so the table + data are preserved; this stops a later disable of the OLD plugin from dropping a table the NEW plugin now owns. Also publish database/tenant-template migrations (previously never copied into projects) so tenant-template splits reconcile correctly. --- .../API/DTOs/FeedbackPage.php | 0 .../API/DTOs/ListFeedbackQuery.php | 0 .../API/DTOs/SubmitFeedbackDTO.php | 0 .../FeedbackSubmittedIntegrationEvent.php | 0 .../Application/Ports/FeedbackStore.php | 0 .../Application/Services/FeedbackService.php | 0 .../Domain/Entities/FeedbackEntry.php | 0 .../Domain/ValueObjects/FeedbackCategory.php | 0 .../Domain/ValueObjects/FeedbackId.php | 0 .../Domain/ValueObjects/FeedbackMessage.php | 0 .../Domain/ValueObjects/FeedbackRating.php | 0 .../Domain/ValueObjects/FeedbackStatus.php | 0 .../Http/Controllers/FeedbackController.php | 0 .../Persistence/FeedbackRepository.php | 0 ...6_29_000005_create_user_feedback_table.php | 0 plugins/Mail/Infrastructure/SmtpMailer.php | 105 -------------- plugins/Mail/Infrastructure/SmtpTransport.php | 129 ----------------- .../User/resources/views/account/feedback.php | 116 --------------- .../FeedbackServiceTest.php | 0 .../Support/FakeFeedbackStore.php | 0 tools/docs/hkm-cli-usage.md | 9 ++ tools/src/commands/plugins.zig | 135 +++++++++++++++++- tools/src/lib/plugin_assets.zig | 109 ++++++++++++++ 23 files changed, 251 insertions(+), 352 deletions(-) rename plugins/{User => Feedback}/API/DTOs/FeedbackPage.php (100%) rename plugins/{User => Feedback}/API/DTOs/ListFeedbackQuery.php (100%) rename plugins/{User => Feedback}/API/DTOs/SubmitFeedbackDTO.php (100%) rename plugins/{User => Feedback}/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php (100%) rename plugins/{User => Feedback}/Application/Ports/FeedbackStore.php (100%) rename plugins/{User => Feedback}/Application/Services/FeedbackService.php (100%) rename plugins/{User => Feedback}/Domain/Entities/FeedbackEntry.php (100%) rename plugins/{User => Feedback}/Domain/ValueObjects/FeedbackCategory.php (100%) rename plugins/{User => Feedback}/Domain/ValueObjects/FeedbackId.php (100%) rename plugins/{User => Feedback}/Domain/ValueObjects/FeedbackMessage.php (100%) rename plugins/{User => Feedback}/Domain/ValueObjects/FeedbackRating.php (100%) rename plugins/{User => Feedback}/Domain/ValueObjects/FeedbackStatus.php (100%) rename plugins/{User => Feedback}/Infrastructure/Http/Controllers/FeedbackController.php (100%) rename plugins/{User => Feedback}/Infrastructure/Persistence/FeedbackRepository.php (100%) rename plugins/{User => Feedback}/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php (100%) delete mode 100644 plugins/Mail/Infrastructure/SmtpMailer.php delete mode 100644 plugins/Mail/Infrastructure/SmtpTransport.php delete mode 100644 plugins/User/resources/views/account/feedback.php rename tests/Unit/Plugins/{User => Feedback}/FeedbackServiceTest.php (100%) rename tests/Unit/Plugins/{User => Feedback}/Support/FakeFeedbackStore.php (100%) diff --git a/plugins/User/API/DTOs/FeedbackPage.php b/plugins/Feedback/API/DTOs/FeedbackPage.php similarity index 100% rename from plugins/User/API/DTOs/FeedbackPage.php rename to plugins/Feedback/API/DTOs/FeedbackPage.php diff --git a/plugins/User/API/DTOs/ListFeedbackQuery.php b/plugins/Feedback/API/DTOs/ListFeedbackQuery.php similarity index 100% rename from plugins/User/API/DTOs/ListFeedbackQuery.php rename to plugins/Feedback/API/DTOs/ListFeedbackQuery.php diff --git a/plugins/User/API/DTOs/SubmitFeedbackDTO.php b/plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php similarity index 100% rename from plugins/User/API/DTOs/SubmitFeedbackDTO.php rename to plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php diff --git a/plugins/User/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php b/plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php similarity index 100% rename from plugins/User/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php rename to plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php diff --git a/plugins/User/Application/Ports/FeedbackStore.php b/plugins/Feedback/Application/Ports/FeedbackStore.php similarity index 100% rename from plugins/User/Application/Ports/FeedbackStore.php rename to plugins/Feedback/Application/Ports/FeedbackStore.php diff --git a/plugins/User/Application/Services/FeedbackService.php b/plugins/Feedback/Application/Services/FeedbackService.php similarity index 100% rename from plugins/User/Application/Services/FeedbackService.php rename to plugins/Feedback/Application/Services/FeedbackService.php diff --git a/plugins/User/Domain/Entities/FeedbackEntry.php b/plugins/Feedback/Domain/Entities/FeedbackEntry.php similarity index 100% rename from plugins/User/Domain/Entities/FeedbackEntry.php rename to plugins/Feedback/Domain/Entities/FeedbackEntry.php diff --git a/plugins/User/Domain/ValueObjects/FeedbackCategory.php b/plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php similarity index 100% rename from plugins/User/Domain/ValueObjects/FeedbackCategory.php rename to plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php diff --git a/plugins/User/Domain/ValueObjects/FeedbackId.php b/plugins/Feedback/Domain/ValueObjects/FeedbackId.php similarity index 100% rename from plugins/User/Domain/ValueObjects/FeedbackId.php rename to plugins/Feedback/Domain/ValueObjects/FeedbackId.php diff --git a/plugins/User/Domain/ValueObjects/FeedbackMessage.php b/plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php similarity index 100% rename from plugins/User/Domain/ValueObjects/FeedbackMessage.php rename to plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php diff --git a/plugins/User/Domain/ValueObjects/FeedbackRating.php b/plugins/Feedback/Domain/ValueObjects/FeedbackRating.php similarity index 100% rename from plugins/User/Domain/ValueObjects/FeedbackRating.php rename to plugins/Feedback/Domain/ValueObjects/FeedbackRating.php diff --git a/plugins/User/Domain/ValueObjects/FeedbackStatus.php b/plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php similarity index 100% rename from plugins/User/Domain/ValueObjects/FeedbackStatus.php rename to plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php diff --git a/plugins/User/Infrastructure/Http/Controllers/FeedbackController.php b/plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php similarity index 100% rename from plugins/User/Infrastructure/Http/Controllers/FeedbackController.php rename to plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php diff --git a/plugins/User/Infrastructure/Persistence/FeedbackRepository.php b/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php similarity index 100% rename from plugins/User/Infrastructure/Persistence/FeedbackRepository.php rename to plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php diff --git a/plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php b/plugins/Feedback/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php similarity index 100% rename from plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php rename to plugins/Feedback/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php diff --git a/plugins/Mail/Infrastructure/SmtpMailer.php b/plugins/Mail/Infrastructure/SmtpMailer.php deleted file mode 100644 index f2fb9ee..0000000 --- a/plugins/Mail/Infrastructure/SmtpMailer.php +++ /dev/null @@ -1,105 +0,0 @@ -render($view, $data); - $message = $this->buildMime($recipients, $subject, $html); - - $this->transport->send([$this->fromEmail, $this->fromName], $recipients, $message); - } - - public function queue(string|array $to, string $subject, string $view, array $data = []): string - { - $this->send($to, $subject, $view, $data); - return 'sync-' . bin2hex(random_bytes(8)); - } - - /** - * Render a PHP template to HTML. If $view contains a newline or '<' it is - * treated as an inline HTML body instead of a template name. - * - * @param array $data - */ - private function render(string $view, array $data): string - { - if (str_contains($view, "\n") || str_contains($view, '<')) { - return $view; // inline HTML - } - - $file = rtrim($this->viewsPath, '/') . '/' . str_replace('.', '/', $view) . '.php'; - if ($this->viewsPath === '' || !is_file($file)) { - throw new GatewayException( - "Mail view [{$view}] not found.", - layer: 'gateway.smtp', - context: ['file' => $file], - ); - } - - return (static function () use ($file, $data): string { - extract($data, EXTR_SKIP); - ob_start(); - include $file; - return (string) ob_get_clean(); - })(); - } - - /** @param list $recipients */ - private function buildMime(array $recipients, string $subject, string $html): string - { - $from = $this->fromName !== '' - ? sprintf('%s <%s>', $this->mimeEncode($this->fromName), $this->fromEmail) - : $this->fromEmail; - - $headers = [ - 'From: ' . $from, - 'To: ' . implode(', ', $recipients), - 'Subject: ' . $this->mimeEncode($subject), - 'MIME-Version: 1.0', - 'Content-Type: text/html; charset=UTF-8', - 'Content-Transfer-Encoding: 8bit', - 'Date: ' . date('r'), - 'Message-ID: <' . bin2hex(random_bytes(12)) . '@' . (gethostname() ?: 'localhost') . '>', - ]; - - return implode("\r\n", $headers) . "\r\n\r\n" . $this->normalizeNewlines($html); - } - - private function mimeEncode(string $value): string - { - return preg_match('/[^\x20-\x7E]/', $value) === 1 - ? '=?UTF-8?B?' . base64_encode($value) . '?=' - : $value; - } - - private function normalizeNewlines(string $body): string - { - return preg_replace('/\r\n|\r|\n/', "\r\n", $body) ?? $body; - } -} diff --git a/plugins/Mail/Infrastructure/SmtpTransport.php b/plugins/Mail/Infrastructure/SmtpTransport.php deleted file mode 100644 index 99d505f..0000000 --- a/plugins/Mail/Infrastructure/SmtpTransport.php +++ /dev/null @@ -1,129 +0,0 @@ - $recipients - */ - public function send(array $from, array $recipients, string $rawMessage): void - { - $this->connect(); - try { - $this->ehlo(); - - if ($this->encryption === 'tls') { - $this->command('STARTTLS', 220); - if (!stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { - throw new GatewayException('STARTTLS negotiation failed.', layer: 'gateway.smtp'); - } - $this->ehlo(); // re-EHLO after upgrading - } - - if ($this->username !== null) { - $this->command('AUTH LOGIN', 334); - $this->command(base64_encode($this->username), 334); - $this->command(base64_encode((string) $this->password), 235); - } - - $this->command('MAIL FROM:<' . $from[0] . '>', 250); - foreach ($recipients as $rcpt) { - $this->command('RCPT TO:<' . $rcpt . '>', 250); - } - $this->command('DATA', 354); - // Dot-stuffing + terminating "." - $body = preg_replace('/^\./m', '..', $rawMessage) ?? $rawMessage; - $this->command($body . "\r\n.", 250); - $this->command('QUIT', 221); - } finally { - $this->close(); - } - } - - private function connect(): void - { - $prefix = $this->encryption === 'ssl' ? 'ssl://' : ''; - $socket = @stream_socket_client( - $prefix . $this->host . ':' . $this->port, - $errno, - $errstr, - $this->timeout, - STREAM_CLIENT_CONNECT, - ); - if ($socket === false) { - throw new GatewayException( - "Could not connect to SMTP host {$this->host}:{$this->port} ({$errstr}).", - layer: 'gateway.smtp', - context: ['errno' => $errno], - ); - } - $this->socket = $socket; - stream_set_timeout($this->socket, $this->timeout); - $this->expect(220); - } - - private function ehlo(): void - { - $host = gethostname() ?: 'localhost'; - $this->command('EHLO ' . $host, 250); - } - - private function command(string $line, int $expected): void - { - fwrite($this->socket, $line . "\r\n"); - $this->expect($expected); - } - - private function expect(int $code): void - { - $response = ''; - while (($line = fgets($this->socket, 515)) !== false) { - $response .= $line; - // Multi-line replies use "250-"; the final line uses "250 ". - if (isset($line[3]) && $line[3] === ' ') { - break; - } - } - - $actual = (int) substr($response, 0, 3); - if ($actual !== $code) { - throw new GatewayException( - "Unexpected SMTP reply: expected {$code}, got " . trim($response), - layer: 'gateway.smtp', - ); - } - } - - private function close(): void - { - if (is_resource($this->socket)) { - @fclose($this->socket); - } - $this->socket = null; - } -} diff --git a/plugins/User/resources/views/account/feedback.php b/plugins/User/resources/views/account/feedback.php deleted file mode 100644 index 61d328e..0000000 --- a/plugins/User/resources/views/account/feedback.php +++ /dev/null @@ -1,116 +0,0 @@ - -

-

Submit feedback

-

POST /ajx/feedback

-
- - - - -
-
-
-
- -
-
-

Triage (admin)

- -
-

GET /ajx/feedback · PATCH /ajx/feedback/{id}

- - - -
IDUserCategoryRatingStatus
Loading…
-
- - diff --git a/tests/Unit/Plugins/User/FeedbackServiceTest.php b/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php similarity index 100% rename from tests/Unit/Plugins/User/FeedbackServiceTest.php rename to tests/Unit/Plugins/Feedback/FeedbackServiceTest.php diff --git a/tests/Unit/Plugins/User/Support/FakeFeedbackStore.php b/tests/Unit/Plugins/Feedback/Support/FakeFeedbackStore.php similarity index 100% rename from tests/Unit/Plugins/User/Support/FakeFeedbackStore.php rename to tests/Unit/Plugins/Feedback/Support/FakeFeedbackStore.php diff --git a/tools/docs/hkm-cli-usage.md b/tools/docs/hkm-cli-usage.md index 8f4b8f4..5c06941 100644 --- a/tools/docs/hkm-cli-usage.md +++ b/tools/docs/hkm-cli-usage.md @@ -106,6 +106,9 @@ hkm worker [args...] run app/worker/run.php in ./ hkm cli # interactive command picker hkm cli list # forward `list` to the project console hkm cli make:migration # interactive prompt +hkm cli route:list # dump the compiled route manifest +hkm cli route:list --method=GET --path=/api # filter by verb + path prefix +hkm cli route:list --json # machine-readable output hkm cli -p shop migrate:run # target a registered project by name hkm worker hkm worker -p shop --queue=emails @@ -142,6 +145,8 @@ When a plugin of the same name exists in both, you are prompted to choose. hkm plugins [path|name] analyse a project's enabled plugins hkm plugins enable [proj] wire a plugin into the bootstrap hkm plugins disable [proj] remove a plugin from the bootstrap +hkm plugins update [plugin] [proj] publish NEW assets of enabled plugin(s) + migrate them +hkm plugins upgrade [proj] full upgrade after plugins changed (deps + assets + SPLIT reconcile) hkm plugins create [proj] scaffold a new plugin hkm plugins delete [proj] delete a plugin folder from disk hkm plugins make:migration add a migration INTO a plugin @@ -164,6 +169,8 @@ hkm plugins make:factory add a factory into a plugin ``` enable = add | on disable = remove | off +update = sync +upgrade = reconcile | migrate create = new | scaffold delete = del | destroy | rm make:migration = make-migration | migration @@ -180,6 +187,8 @@ hkm plugins --all # also show available, disabled plugins hkm plugins enable billing # wire + publish + migrate hkm plugins enable redis-cache -e # into withEssentialModules() hkm plugins disable billing # un-wire (then asks to unpublish) +hkm plugins upgrade # after upgrading plugins: heal deps, publish/migrate, reconcile splits +hkm plugins upgrade shop --dry-run # preview the whole upgrade for a project hkm plugins create loyalty # scaffold a project plugin hkm plugins create http2 --kernel # scaffold a kernel plugin (contributors) hkm plugins delete loyalty # delete a plugin folder (confirms first) diff --git a/tools/src/commands/plugins.zig b/tools/src/commands/plugins.zig index 003d3a8..e8aad7c 100644 --- a/tools/src/commands/plugins.zig +++ b/tools/src/commands/plugins.zig @@ -27,7 +27,7 @@ const Located = sources.Located; const Enabled = boot.Enabled; const Activation = boot.Activation; -const Action = enum { analyze, verify, enable, disable, update, create, delete, make_migration, make_seeder, make_factory }; +const Action = enum { analyze, verify, enable, disable, update, upgrade, create, delete, make_migration, make_seeder, make_factory }; pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []const u8) !u8 { var action: Action = .analyze; @@ -92,6 +92,7 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c } return updatePlugins(allocator, io, env, op(ops, 0), op(ops, 1), dry_run); }, + .upgrade => return upgradeProject(allocator, io, env, op(ops, 0), dry_run), .create => { if (ops.len == 0) { prompt.err("Usage: hkm plugins create [path|name] [--kernel] [--dry-run]"); @@ -134,7 +135,8 @@ fn actionFromWord(a: []const u8) Action { fn actionFromWordOpt(a: []const u8) ?Action { if (std.mem.eql(u8, a, "enable") or std.mem.eql(u8, a, "add") or std.mem.eql(u8, a, "on")) return .enable; if (std.mem.eql(u8, a, "disable") or std.mem.eql(u8, a, "remove") or std.mem.eql(u8, a, "off")) return .disable; - if (std.mem.eql(u8, a, "update") or std.mem.eql(u8, a, "sync") or std.mem.eql(u8, a, "upgrade")) return .update; + if (std.mem.eql(u8, a, "update") or std.mem.eql(u8, a, "sync")) return .update; + if (std.mem.eql(u8, a, "upgrade") or std.mem.eql(u8, a, "reconcile") or std.mem.eql(u8, a, "migrate")) return .upgrade; if (std.mem.eql(u8, a, "create") or std.mem.eql(u8, a, "new") or std.mem.eql(u8, a, "scaffold")) return .create; if (std.mem.eql(u8, a, "delete") or std.mem.eql(u8, a, "del") or std.mem.eql(u8, a, "destroy") or std.mem.eql(u8, a, "rm")) return .delete; @@ -1012,6 +1014,133 @@ fn updatePlugins( return 0; } +// ── upgrade (heal deps + publish/migrate + reconcile split ownership) ────────── + +/// Full project upgrade after its plugins changed. Three phases, each idempotent: +/// +/// 1. Dependency healing — a plugin that gained a new `requires` domain has its +/// missing provider auto-enabled (on-demand), so new cross-plugin deps that +/// appeared since the plugin was first enabled are wired in. +/// 2. Assets + migrations — every enabled plugin's NEW assets are published and +/// its pending migrations run (delegates to `update`; already-applied +/// migrations are skipped by name, so nothing re-runs). +/// 3. Split reconciliation — when a plugin SPLIT (a migration/table moved to a +/// new plugin), the manifest's migration ownership is transferred to the new +/// owner. No DDL runs: the shared `let_migrations` row (keyed by filename) +/// still marks the migration applied, so the table + its data are preserved. +/// This is the data-safety step — it prevents a later disable of the OLD +/// plugin from dropping a table the NEW plugin now owns. +fn upgradeProject(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8, dry_run: bool) !u8 { + const root = (try requireRoot(allocator, io, env, target)) orelse return 1; + + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + const source = (try readBootstrap(allocator, io, bootstrap)) orelse return 1; + + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, search, &cat); + + prompt.intro("hkm plugins upgrade"); + prompt.ok(try std.fmt.allocPrint(allocator, "project {s}", .{root})); + + if (enabled.items.len == 0) { + prompt.warn("No plugins enabled in this project."); + prompt.outro("Nothing to upgrade"); + return 0; + } + + // ── Phase 1: dependency healing ──────────────────────────────────────────── + const Step = struct { folder: []const u8, dir: ?[]const u8 }; + var plan: std.ArrayList(Step) = .empty; + for (enabled.items) |e| { + var needed: std.ArrayList(deps.Provider) = .empty; + var missing: std.ArrayList([]const u8) = .empty; + try deps.requiredClosure(allocator, cat.items, e.name, &needed, &missing); + for (needed.items) |dep| { + if (boot.findEnabled(enabled.items, dep.located.name) != null) continue; // already wired + var seen = false; + for (plan.items) |s| { + if (util.eqlIgnoreCase(s.folder, dep.located.name)) seen = true; + } + if (seen) continue; + try plan.append(allocator, .{ .folder = dep.located.name, .dir = dep.located.dir }); + } + } + + if (plan.items.len > 0) { + prompt.section("New dependencies to enable"); + for (plan.items) |s| { + const prov = deps.findByName(cat.items, s.folder); + const solves = if (prov) |p| (p.solves orelse "—") else "—"; + prompt.muted(try std.fmt.allocPrint(allocator, " {s} (solves: {s})", .{ s.folder, solves })); + } + var cur = source; + for (plan.items) |s| { + cur = (try enableOne(allocator, io, env, root, cur, s.folder, s.dir, false, true, dry_run)) orelse return 1; + } + if (!dry_run) { + try Dir.cwd().writeFile(io, .{ .sub_path = bootstrap, .data = cur }); + prompt.ok(try std.fmt.allocPrint(allocator, "Wired {d} new dependency plugin(s) into the bootstrap", .{plan.items.len})); + } + } else { + prompt.muted("Dependencies: all required providers already enabled."); + } + + // ── Phase 2: publish NEW assets + run pending migrations for every plugin ──── + prompt.section("Assets + migrations"); + _ = try updatePlugins(allocator, io, env, "", target, dry_run); + + // ── Phase 3: reconcile migration ownership across plugin splits ───────────── + prompt.section("Split reconciliation (migration ownership)"); + + // Re-read the bootstrap: Phase 1 may have enabled new plugins. + const source2 = (try readBootstrap(allocator, io, bootstrap)) orelse source; + var aliases2: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source2, &aliases2); + var enabled2: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source2, aliases2.items, &enabled2); + + var plugin_dirs: std.ArrayList(assets.PluginDir) = .empty; + for (enabled2.items) |e| { + for (search) |src| { + const d = srcs.dirFor(src) orelse continue; + const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ d, e.name }); + if (util.dirExists(Dir.cwd(), io, fp)) { + try plugin_dirs.append(allocator, .{ .name = e.name, .dir = fp }); + break; + } + } + } + + var moves: std.ArrayList(assets.MigrationMove) = .empty; + try assets.reconcileMigrationOwnership(allocator, io, root, plugin_dirs.items, dry_run, &moves); + + if (moves.items.len == 0) { + prompt.muted("No migration moved between plugins — ownership already correct."); + } else { + const verb = if (dry_run) "Would transfer" else "Transferred"; + prompt.ok(try std.fmt.allocPrint(allocator, "{s} {d} migration(s) to their new plugin owner (no data touched)", .{ verb, moves.items.len })); + for (moves.items) |mv| { + prompt.muted(try std.fmt.allocPrint(allocator, " {s} {s} → {s}", .{ std.fs.path.basename(mv.path), mv.from, mv.to })); + } + prompt.note("Tables + data preserved — the migration stays applied; only manifest ownership changed."); + } + + if (dry_run) { + prompt.outro("Dry run — no files, bootstrap or database changed"); + return 0; + } + prompt.outro("Upgrade complete"); + return 0; +} + fn containsStr(haystack: []const []const u8, needle: []const u8) bool { for (haystack) |h| { if (std.mem.eql(u8, h, needle)) return true; @@ -1330,6 +1459,7 @@ fn printHelp() void { prompt.item("hkm plugins enable [proj]", "wire a plugin into the project bootstrap"); prompt.item("hkm plugins disable [proj]", "remove a plugin from the project bootstrap"); prompt.item("hkm plugins update [plugin] [proj]", "publish NEW assets of enabled plugin(s) + migrate them; wire any missing Support/helpers.php require"); + prompt.item("hkm plugins upgrade [proj]", "full upgrade after plugins changed: heal new deps, publish/migrate, reconcile plugin SPLITS (moves migration ownership without dropping data)"); prompt.item("hkm plugins create [proj]", "scaffold a new plugin (project, or --kernel)"); prompt.item("hkm plugins delete [proj]", "delete a plugin folder from disk"); prompt.item("hkm plugins make:migration ", "add a migration INTO a plugin (not published)"); @@ -1351,6 +1481,7 @@ fn printHelp() void { prompt.section("Notes"); prompt.item("enable", "resolves requires[] deps (e.g. Tenancy → Database/Auth/User), publishes assets + migrate:run"); prompt.item("disable", "won't orphan dependents (offers to cascade); offers to prune now-unused deps, keeping shared ones"); + prompt.item("upgrade", "split-safe: a migration moved to a new plugin keeps its data; only manifest ownership transfers, no DDL re-runs (aliases: reconcile/migrate)"); prompt.item("create", "scaffolds a complete plugin (config, migration, seeder, factory, view)"); prompt.item("Support helpers", "a plugin's Support/helpers.php is require_once'd in the bootstrap on enable, removed on disable"); prompt.item("aliases", "enable=add/on · disable=remove/off · create=new/make · delete=del/rm"); diff --git a/tools/src/lib/plugin_assets.zig b/tools/src/lib/plugin_assets.zig index 4a9b471..617906d 100644 --- a/tools/src/lib/plugin_assets.zig +++ b/tools/src/lib/plugin_assets.zig @@ -19,11 +19,29 @@ const EnvMap = std.process.Environ.Map; pub const subtrees = [_][]const u8{ "config", "database/migrations", + "database/tenant-template", "database/seeders", "database/factories", "resources", }; +/// Subtrees whose files are MIGRATIONS (applied to a DB), used when reconciling +/// migration ownership after a plugin split. `database/migrations` runs on the +/// central DB; `database/tenant-template` is provisioned into every tenant DB. +pub const migration_subtrees = [_][]const u8{ + "database/migrations", + "database/tenant-template", +}; + +/// True when `rel` is a migration file (lives under a migration subtree). +pub fn isMigrationPath(rel: []const u8) bool { + for (migration_subtrees) |sub| { + const pfx = if (sub.len > 0) sub else ""; + if (std.mem.startsWith(u8, rel, pfx) and rel.len > pfx.len and rel[pfx.len] == '/') return true; + } + return false; +} + /// Copy a plugin's publishable assets into the project (OVERWRITING existing /// files). Project-relative paths of every written file are appended to `out`. pub fn publishAssets( @@ -570,6 +588,97 @@ pub fn unpublishPlugin(allocator: std.mem.Allocator, io: Io, env: *EnvMap, proje prompt.muted(try std.fmt.allocPrint(allocator, " removed {d} published file(s).", .{removed})); } +// ── migration-ownership reconciliation (plugin splits) ───────────────────────── + +/// A plugin folder on disk: its name + absolute path (e.g. .../plugins/Feedback). +pub const PluginDir = struct { name: []const u8, dir: []const u8 }; + +/// A migration whose manifest ownership was transferred from one plugin to +/// another because the file moved between plugins (a split). +pub const MigrationMove = struct { path: []const u8, from: []const u8, to: []const u8 }; + +/// The name of the plugin in `plugins` that currently SHIPS the migration file +/// `base` (matched by basename across every migration subtree), or null. +fn currentMigrationOwner(allocator: std.mem.Allocator, io: Io, plugins: []const PluginDir, base: []const u8) ?[]const u8 { + for (plugins) |pl| { + for (migration_subtrees) |sub| { + const f = std.fmt.allocPrint(allocator, "{s}/{s}/{s}", .{ util.trimSlash(pl.dir), sub, base }) catch continue; + if (util.fileExists(io, f)) return pl.name; + } + } + return null; +} + +fn removePathFromEntry(allocator: std.mem.Allocator, entries: *std.ArrayList(Entry), name: []const u8, path: []const u8) !void { + for (entries.items) |*e| { + if (!util.eqlIgnoreCase(e.name, name)) continue; + var kept: std.ArrayList([]const u8) = .empty; + for (e.paths) |p| { + if (!std.mem.eql(u8, p, path)) try kept.append(allocator, p); + } + e.paths = try kept.toOwnedSlice(allocator); + return; + } +} + +fn addPathToEntry(allocator: std.mem.Allocator, entries: *std.ArrayList(Entry), name: []const u8, path: []const u8) !void { + for (entries.items) |*e| { + if (!util.eqlIgnoreCase(e.name, name)) continue; + for (e.paths) |p| { + if (std.mem.eql(u8, p, path)) return; // already owned + } + var list: std.ArrayList([]const u8) = .empty; + for (e.paths) |p| try list.append(allocator, p); + try list.append(allocator, path); + e.paths = try list.toOwnedSlice(allocator); + return; + } + // No entry for the new owner yet — create one (the plugin was just enabled). + var list: std.ArrayList([]const u8) = .empty; + try list.append(allocator, path); + try entries.append(allocator, .{ .name = try allocator.dupe(u8, name), .paths = try list.toOwnedSlice(allocator) }); +} + +/// Reassign, in the plugin-assets manifest, every tracked migration to the plugin +/// that currently SHIPS it. When a plugin split moves a migration file to a new +/// plugin (same filename, new owner), its manifest ownership transfers WITHOUT +/// touching the database: the migration's row in the shared `let_migrations` +/// table is keyed by filename and still marks it applied, so the table AND its +/// data are preserved. This is what stops a later `disable` of the OLD plugin +/// from rolling back — and DROPPING — a table the NEW plugin now owns. When +/// `dry_run`, moves are detected and returned but the manifest is not rewritten. +pub fn reconcileMigrationOwnership( + allocator: std.mem.Allocator, + io: Io, + projectRoot: []const u8, + plugins: []const PluginDir, + dry_run: bool, + moves: *std.ArrayList(MigrationMove), +) !void { + var entries = try readManifest(allocator, io, projectRoot); + if (entries.items.len == 0) return; + + // Detect: any tracked migration whose current shipping owner differs from + // the plugin it is recorded under. + for (entries.items) |e| { + for (e.paths) |p| { + if (!isMigrationPath(p)) continue; + const base = std.fs.path.basename(p); + const owner = currentMigrationOwner(allocator, io, plugins, base) orelse continue; + if (!util.eqlIgnoreCase(owner, e.name)) { + try moves.append(allocator, .{ .path = p, .from = e.name, .to = owner }); + } + } + } + if (moves.items.len == 0 or dry_run) return; + + for (moves.items) |mv| { + try removePathFromEntry(allocator, &entries, mv.from, mv.path); + try addPathToEntry(allocator, &entries, mv.to, mv.path); + } + try writeManifest(allocator, io, projectRoot, entries.items); +} + /// Publish every plugin currently enabled in a project's bootstrap (copy only, /// no auto-migrate). Used by `hkm new`. Safe to call repeatedly. pub fn publishEnabled(allocator: std.mem.Allocator, io: Io, env: *EnvMap, projectRoot: []const u8) !void { From dca5f0b74df013b3f49dfa1a2b554e26f1ab6a28 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 01:18:18 +0300 Subject: [PATCH 021/140] chore(release): v1.0.7 --- CHANGELOG.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7debaf..65fd4d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.7] - 2026-07-11 + +### Added +- **`hkm plugins upgrade [project]` — split-safe project upgrade.** A new + command (aliases: `reconcile`, `migrate`) that upgrades a project after the + plugins it depends on have changed, in three idempotent phases: (1) dependency + healing — auto-enable the provider of any domain a plugin newly `requires`; + (2) assets + migrations — publish each enabled plugin's NEW assets and run its + pending migrations (delegates to `update`; already-applied migrations skip by + name); (3) **split reconciliation** — when a plugin SPLITS and a migration file + moves to a new plugin (e.g. Feedback extracted from User), the migration's + ownership in `var/plugin-assets.json` transfers to the new owner WITHOUT + touching the database. The shared `let_migrations` row is keyed by filename and + stays applied, so the table AND its data are preserved — and a later `disable` + of the OLD plugin can no longer roll back (drop) a table the NEW plugin owns. + +### Fixed +- Plugin asset publishing now includes `database/tenant-template` migrations + (previously never copied into projects), so tenant-scoped tables ship on + enable/update and tenant-template splits reconcile correctly. + ## [1.0.6] - 2026-07-09 ### Added @@ -144,7 +165,9 @@ macOS, and Windows, built and published automatically from a `v*` tag. (`phpunit.xml` is gitignored). - Windows cross-compilation: guarded POSIX-only raw-mode TTY code. -[Unreleased]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.5...HEAD +[Unreleased]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.7...HEAD +[1.0.7]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.6...v1.0.7 +[1.0.6]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.5...v1.0.6 [1.0.5]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.4...v1.0.5 [1.0.4]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.3...v1.0.4 [1.0.3]: https://github.com/AlfaCode-Team/php-service-platform/compare/v1.0.2...v1.0.3 From d5a8ff6b52c12ae72aeec6e6900c1506157778dd Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 01:32:33 +0300 Subject: [PATCH 022/140] =?UTF-8?q?test(feedback):=20fix=20FeedbackService?= =?UTF-8?q?Test=20namespace=20after=20User=E2=86=92Feedback=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test moved to tests/Unit/Plugins/Feedback/ but still declared the Tests\Unit\Plugins\User namespace and referenced a FakeFeedbackStore under User\Support that no longer exists, causing 11 CI errors. Point it at Tests\Unit\Plugins\Feedback\Support\FakeFeedbackStore. --- tests/Unit/Plugins/Feedback/FeedbackServiceTest.php | 13 +++++++------ .../Plugins/Feedback/Support/FakeFeedbackStore.php | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php b/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php index 2bc26ed..535ecb1 100644 --- a/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php +++ b/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Unit\Plugins\User; +namespace Tests\Unit\Plugins\Feedback; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\EventBus; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\SecurityException; @@ -10,12 +10,13 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Plugins\User\API\DTOs\ListFeedbackQuery; -use Plugins\User\API\DTOs\SubmitFeedbackDTO; -use Plugins\User\Application\Services\FeedbackService; -use Plugins\User\Infrastructure\Audit\AuditLogger; +use Plugins\Feedback\API\DTOs\ListFeedbackQuery; +use Plugins\Feedback\API\DTOs\SubmitFeedbackDTO; +use Plugins\Feedback\Application\Services\FeedbackService; +use Plugins\Feedback\Infrastructure\Audit\AuditLogger; use Psr\Container\ContainerInterface; -use Tests\Unit\Plugins\User\Support\FakeFeedbackStore; +use Tests\Unit\Plugins\Feedback\Support\FakeFeedbackStore; +use Tests\Unit\Plugins\User\FakeRequest; #[CoversClass(FeedbackService::class)] final class FeedbackServiceTest extends TestCase diff --git a/tests/Unit/Plugins/Feedback/Support/FakeFeedbackStore.php b/tests/Unit/Plugins/Feedback/Support/FakeFeedbackStore.php index 8354598..99d679a 100644 --- a/tests/Unit/Plugins/Feedback/Support/FakeFeedbackStore.php +++ b/tests/Unit/Plugins/Feedback/Support/FakeFeedbackStore.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Tests\Unit\Plugins\User\Support; +namespace Tests\Unit\Plugins\Feedback\Support; -use Plugins\User\API\DTOs\ListFeedbackQuery; -use Plugins\User\Application\Ports\FeedbackStore; -use Plugins\User\Domain\Entities\FeedbackEntry; +use Plugins\Feedback\API\DTOs\ListFeedbackQuery; +use Plugins\Feedback\Application\Ports\FeedbackStore; +use Plugins\Feedback\Domain\Entities\FeedbackEntry; /** * In-memory FeedbackStore for service tests. Insertion order is newest-last; From 132c9f79b24b583b6cbdc2ea393fe6112fb5bb56 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 01:50:26 +0300 Subject: [PATCH 023/140] refactor(plugins): complete Feedback/Mail/Validation split Finish the plugin extraction that main was left mid-way through (the committed intermediate state fatals in CI with 'Cannot redeclare interface Plugins\User\...\FeedbackStore' because the moved files still carried the Plugins\User namespace and the new plugin providers were never committed). - Feedback: extracted from User into its own Plugins\Feedback plugin (Provider, module.json, DTOs, domain, service, repository, audit, tenant-template migration) so each plugin owns exactly one domain. - Mail: native dependency-free MailPort plugin (API/Application/Domain/ Infrastructure/config). - Validation: shared Validator + AbstractDto rule engine plugin. - User: email-verification token flow, tenant profile provisioning listener, and namespace/DTO cleanups after the Feedback extraction. - Tests realigned to the new namespaces; full suite green (444/444). --- .../Http/Commands/RouteListCommand.php | 210 ++++++++ plugins/Commands/Provider.php | 5 +- plugins/Feedback/API/DTOs/FeedbackPage.php | 4 +- .../Feedback/API/DTOs/ListFeedbackQuery.php | 4 +- .../Feedback/API/DTOs/SubmitFeedbackDTO.php | 8 +- .../FeedbackSubmittedIntegrationEvent.php | 2 +- .../Application/Ports/FeedbackStore.php | 6 +- .../Application/Services/FeedbackService.php | 18 +- .../Domain/Entities/FeedbackEntry.php | 12 +- .../Domain/ValueObjects/FeedbackCategory.php | 2 +- .../Domain/ValueObjects/FeedbackId.php | 2 +- .../Domain/ValueObjects/FeedbackMessage.php | 2 +- .../Domain/ValueObjects/FeedbackRating.php | 2 +- .../Domain/ValueObjects/FeedbackStatus.php | 2 +- plugins/Feedback/Domain/ValueObjects/Ulid.php | 57 +++ .../Infrastructure/Audit/AuditLogger.php | 106 ++++ .../Http/Controllers/FeedbackController.php | 8 +- .../Persistence/FeedbackRepository.php | 8 +- plugins/Feedback/Provider.php | 95 ++++ plugins/Feedback/README.md | 49 ++ plugins/Feedback/module.json | 23 + plugins/Mail/API/Contracts/MailerContract.php | 28 ++ plugins/Mail/Application/Jobs/SendMailJob.php | 45 ++ plugins/Mail/Application/Mailer.php | 155 ++++++ plugins/Mail/Domain/Address.php | 54 ++ plugins/Mail/Domain/Attachment.php | 85 ++++ plugins/Mail/Domain/MailException.php | 10 + plugins/Mail/Domain/Message.php | 211 ++++++++ plugins/Mail/Domain/Priority.php | 22 + .../Mail/Infrastructure/Mime/MimeBuilder.php | 275 ++++++++++ .../Infrastructure/Security/DkimSigner.php | 106 ++++ .../Transport/ArrayTransport.php | 42 ++ .../Infrastructure/Transport/LogTransport.php | 27 + .../Transport/MailTransport.php | 52 ++ .../Transport/SendmailTransport.php | 52 ++ .../Transport/SmtpTransport.php | 288 +++++++++++ .../Infrastructure/Transport/Transport.php | 22 + plugins/Mail/Provider.php | 129 ++++- plugins/Mail/README.md | 109 ++++ plugins/Mail/config/mail.php | 49 ++ plugins/Mail/module.json | 43 +- plugins/Settings/module.json | 2 +- plugins/Tenancy/module.json | 2 +- .../API/Contracts/UserServiceContract.php | 15 + plugins/User/API/DTOs/RegisterUserDTO.php | 93 ++-- .../DTOs/UpdateNotificationPreferencesDTO.php | 22 +- .../User/API/DTOs/UpdatePreferencesDTO.php | 49 +- plugins/User/API/DTOs/UpdatePrivacyDTO.php | 23 +- plugins/User/API/DTOs/UpdateProfileDTO.php | 77 ++- plugins/User/API/DTOs/UpdateUserDTO.php | 70 +-- plugins/User/API/DTOs/VerifyEmailDTO.php | 21 +- .../UserRegisteredIntegrationEvent.php | 13 +- plugins/User/Application/Ports/UserStore.php | 3 + .../Services/TenantProfileProvisioner.php | 50 ++ .../User/Application/Services/UserService.php | 101 +++- plugins/User/Domain/Entities/User.php | 32 +- .../Http/Controllers/UserController.php | 65 ++- .../Http/Controllers/UserPageController.php | 6 - .../ProvisionTenantProfileListener.php | 67 +++ .../Persistence/UserRepository.php | 31 +- plugins/User/Provider.php | 42 +- plugins/User/README.md | 14 +- ..._add_email_verification_token_to_users.php | 46 ++ plugins/User/module.json | 12 +- .../User/resources/views/emails/verify.php | 49 ++ plugins/User/resources/views/layouts/app.php | 1 - plugins/Validation/AbstractDto.php | 93 ++++ plugins/Validation/Provider.php | 77 +++ plugins/Validation/README.md | 159 ++++++ plugins/Validation/Rules/CommonRules.php | 473 ++++++++++++++++++ plugins/Validation/Rules/FinancialRules.php | 100 ++++ plugins/Validation/Validator.php | 162 +++++- plugins/Validation/config/validation.php | 39 ++ plugins/Validation/module.json | 17 + templates/app/bootstrap/app.php | 26 + .../Plugins/Auth/Support/FakeUserService.php | 2 + tests/Unit/Plugins/Mail/MailerTest.php | 142 ++++++ .../Plugins/User/Support/FakeUserStore.php | 13 + .../Unit/Plugins/Validation/ValidatorTest.php | 100 ++++ 79 files changed, 4442 insertions(+), 296 deletions(-) create mode 100644 plugins/Commands/Infrastructure/Http/Commands/RouteListCommand.php create mode 100644 plugins/Feedback/Domain/ValueObjects/Ulid.php create mode 100644 plugins/Feedback/Infrastructure/Audit/AuditLogger.php create mode 100644 plugins/Feedback/Provider.php create mode 100644 plugins/Feedback/README.md create mode 100644 plugins/Feedback/module.json create mode 100644 plugins/Mail/API/Contracts/MailerContract.php create mode 100644 plugins/Mail/Application/Jobs/SendMailJob.php create mode 100644 plugins/Mail/Application/Mailer.php create mode 100644 plugins/Mail/Domain/Address.php create mode 100644 plugins/Mail/Domain/Attachment.php create mode 100644 plugins/Mail/Domain/MailException.php create mode 100644 plugins/Mail/Domain/Message.php create mode 100644 plugins/Mail/Domain/Priority.php create mode 100644 plugins/Mail/Infrastructure/Mime/MimeBuilder.php create mode 100644 plugins/Mail/Infrastructure/Security/DkimSigner.php create mode 100644 plugins/Mail/Infrastructure/Transport/ArrayTransport.php create mode 100644 plugins/Mail/Infrastructure/Transport/LogTransport.php create mode 100644 plugins/Mail/Infrastructure/Transport/MailTransport.php create mode 100644 plugins/Mail/Infrastructure/Transport/SendmailTransport.php create mode 100644 plugins/Mail/Infrastructure/Transport/SmtpTransport.php create mode 100644 plugins/Mail/Infrastructure/Transport/Transport.php create mode 100644 plugins/Mail/README.md create mode 100644 plugins/Mail/config/mail.php create mode 100644 plugins/User/Application/Services/TenantProfileProvisioner.php create mode 100644 plugins/User/Infrastructure/Listeners/ProvisionTenantProfileListener.php create mode 100644 plugins/User/database/migrations/2026_01_01_000002_add_email_verification_token_to_users.php create mode 100644 plugins/User/resources/views/emails/verify.php create mode 100644 plugins/Validation/AbstractDto.php create mode 100644 plugins/Validation/Provider.php create mode 100644 plugins/Validation/README.md create mode 100644 plugins/Validation/Rules/CommonRules.php create mode 100644 plugins/Validation/Rules/FinancialRules.php create mode 100644 plugins/Validation/config/validation.php create mode 100644 plugins/Validation/module.json create mode 100644 tests/Unit/Plugins/Mail/MailerTest.php diff --git a/plugins/Commands/Infrastructure/Http/Commands/RouteListCommand.php b/plugins/Commands/Infrastructure/Http/Commands/RouteListCommand.php new file mode 100644 index 0000000..775362e --- /dev/null +++ b/plugins/Commands/Infrastructure/Http/Commands/RouteListCommand.php @@ -0,0 +1,210 @@ +name = 'route:list'; + $this->description = 'List all routes compiled into the route manifest'; + $this->help = <<<'HELP' +Reads the compiled route manifest (var/cache/manifests/route-manifest.php) and +prints every registered route with its handler, owning module/scope, filters and +per-route module requires. + +Project routes resolve under the synthetic "__project__" scope; a project route +that overrides a plugin route shows the overridden module. + +Options: + --method=VERB Only routes matching this HTTP method (case-insensitive) + --path=PREFIX Only routes whose path starts with PREFIX + --json Emit the raw manifest as JSON (for scripting) + +Examples: + hkm route:list + hkm route:list --method=POST + hkm route:list --path=/api/invoices + hkm route:list --json +HELP; + + $this->addOption('method', 'm', 'Filter by HTTP method', acceptsValue: true); + $this->addOption('path', 'p', 'Filter by path prefix', acceptsValue: true); + $this->addOption('json', 'j', 'Output the manifest as JSON'); + } + + protected function handle(): int + { + $manifest = $this->loadManifest(); + + if ($manifest === null) { + $this->alertWarning('Route manifest not found', [ + 'Expected: ' . Paths::cache('manifests/route-manifest.php'), + 'Boot the app once (any entry point) to compile it, then retry.', + ]); + return self::FAILURE; + } + + $methodFilter = strtoupper(trim((string) $this->option('method', ''))); + $pathFilter = (string) $this->option('path', ''); + + $rows = $this->filterRoutes($manifest, $methodFilter, $pathFilter); + + if ($this->hasOption('json')) { + echo json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; + return self::SUCCESS; + } + + if ($rows === []) { + $this->info('No routes match the given filters.'); + return self::SUCCESS; + } + + $this->section('Registered routes'); + + $tableRows = []; + foreach ($rows as $key => $entry) { + [$method, $path] = array_pad(explode(' ', $key, 2), 2, ''); + + $tableRows[] = [ + $this->colorMethod($method), + $path, + (string) ($entry['handler'] ?? '—'), + $this->scopeLabel($entry), + $this->listLabel($entry['filters'] ?? []), + $this->listLabel($entry['requires'] ?? []), + ]; + } + + $this->table() + ->headers(['Method', 'Path', 'Handler', 'Scope', 'Filters', 'Requires']) + ->rows($tableRows) + ->render(); + + $this->muted(' ' . count($rows) . ' route' . (count($rows) === 1 ? '' : 's') . ' shown'); + + return self::SUCCESS; + } + + /** + * @return array>|null + */ + private function loadManifest(): ?array + { + $path = Paths::cache('manifests/route-manifest.php'); + + if (!is_file($path)) { + return null; + } + + /** @var mixed $manifest */ + $manifest = require $path; + + return is_array($manifest) ? $manifest : []; + } + + /** + * @param array> $manifest + * @return array> + */ + private function filterRoutes(array $manifest, string $methodFilter, string $pathFilter): array + { + $filtered = []; + + foreach ($manifest as $key => $entry) { + [$method, $path] = array_pad(explode(' ', $key, 2), 2, ''); + + if ($methodFilter !== '' && strtoupper($method) !== $methodFilter) { + continue; + } + if ($pathFilter !== '' && !str_starts_with($path, $pathFilter)) { + continue; + } + + $filtered[$key] = $entry; + } + + // Deterministic order: path, then method. + uksort($filtered, static function (string $a, string $b): int { + [$ma, $pa] = array_pad(explode(' ', $a, 2), 2, ''); + [$mb, $pb] = array_pad(explode(' ', $b, 2), 2, ''); + return [$pa, $ma] <=> [$pb, $mb]; + }); + + return $filtered; + } + + private function colorMethod(string $method): string + { + $method = strtoupper($method); + + return match ($method) { + 'GET' => Colors::wrap($method, Colors::GREEN), + 'POST' => Colors::wrap($method, Colors::BLUE), + 'PUT', + 'PATCH' => Colors::wrap($method, Colors::YELLOW), + 'DELETE' => Colors::wrap($method, Colors::RED), + default => Colors::muted($method), + }; + } + + /** + * @param array $entry + */ + private function scopeLabel(array $entry): string + { + $solves = (string) ($entry['solves'] ?? ''); + + if ($solves === '__project__') { + $overrides = $entry['overrides'] ?? null; + $label = Colors::wrap('project', Colors::CYAN); + return $overrides !== null + ? $label . Colors::muted(' (overrides ' . $this->shortClass((string) $overrides) . ')') + : $label; + } + + return $solves !== '' ? $solves : Colors::muted('—'); + } + + /** + * @param mixed $list + */ + private function listLabel(mixed $list): string + { + if (!is_array($list) || $list === []) { + return Colors::muted('—'); + } + + return implode(', ', array_map(static fn($v): string => (string) $v, $list)); + } + + private function shortClass(string $class): string + { + $parts = explode('\\', $class); + return end($parts) ?: $class; + } +} diff --git a/plugins/Commands/Provider.php b/plugins/Commands/Provider.php index f9310bb..90abfef 100644 --- a/plugins/Commands/Provider.php +++ b/plugins/Commands/Provider.php @@ -14,7 +14,7 @@ use Plugins\Commands\Configuration\EnvironmentConfigurationLoader; use Plugins\Commands\Exceptions\ConfigurationException; use Plugins\Commands\Configuration\ConfigurationValidator; -use Plugins\Commands\Infrastructure\Http\Commands\{ModuleAddCommand, ModuleRemoveCommand}; +use Plugins\Commands\Infrastructure\Http\Commands\{ModuleAddCommand, ModuleRemoveCommand, RouteListCommand}; use Plugins\Commands\Application\Services\ModuleManagementService; use Plugins\Commands\Application\Services\MigrationService; use Plugins\Commands\API\Contracts\{ModuleManagementServiceContract, MigrationServiceContract}; @@ -190,6 +190,9 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke $cli->command($scoped->makeInScope(ModuleAddCommand::class, $this->solves())); $cli->command($scoped->makeInScope(ModuleRemoveCommand::class, $this->solves())); + // Read-only introspection — no DB deps, resolves straight from the manifest. + $cli->command(new RouteListCommand()); + // ── Migration Commands with Enterprise Safeguards ────────────── try { $migrateConfig = $this->loadConfiguration(); diff --git a/plugins/Feedback/API/DTOs/FeedbackPage.php b/plugins/Feedback/API/DTOs/FeedbackPage.php index f539bb2..00ff5da 100644 --- a/plugins/Feedback/API/DTOs/FeedbackPage.php +++ b/plugins/Feedback/API/DTOs/FeedbackPage.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Plugins\User\API\DTOs; +namespace Plugins\Feedback\API\DTOs; -use Plugins\User\Domain\Entities\FeedbackEntry; +use Plugins\Feedback\Domain\Entities\FeedbackEntry; /** * A single keyset page of feedback entries plus the cursor for the next page. diff --git a/plugins/Feedback/API/DTOs/ListFeedbackQuery.php b/plugins/Feedback/API/DTOs/ListFeedbackQuery.php index 3d7ef73..6df2904 100644 --- a/plugins/Feedback/API/DTOs/ListFeedbackQuery.php +++ b/plugins/Feedback/API/DTOs/ListFeedbackQuery.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Plugins\User\API\DTOs; +namespace Plugins\Feedback\API\DTOs; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; -use Plugins\User\Domain\ValueObjects\FeedbackStatus; +use Plugins\Feedback\Domain\ValueObjects\FeedbackStatus; /** * Keyset-pagination query for admin feedback triage. diff --git a/plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php b/plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php index f801d2b..ffd8713 100644 --- a/plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php +++ b/plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Plugins\User\API\DTOs; +namespace Plugins\Feedback\API\DTOs; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; -use Plugins\User\Domain\ValueObjects\FeedbackCategory; -use Plugins\User\Domain\ValueObjects\FeedbackMessage; -use Plugins\User\Domain\ValueObjects\FeedbackRating; +use Plugins\Feedback\Domain\ValueObjects\FeedbackCategory; +use Plugins\Feedback\Domain\ValueObjects\FeedbackMessage; +use Plugins\Feedback\Domain\ValueObjects\FeedbackRating; /** * Validated feedback-submission input. Field shape is validated HERE; the value diff --git a/plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php b/plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php index c7940bd..6af33c3 100644 --- a/plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php +++ b/plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Plugins\User\API\IntegrationEvents; +namespace Plugins\Feedback\API\IntegrationEvents; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\Contracts\IntegrationEventContract; diff --git a/plugins/Feedback/Application/Ports/FeedbackStore.php b/plugins/Feedback/Application/Ports/FeedbackStore.php index 1543028..713f517 100644 --- a/plugins/Feedback/Application/Ports/FeedbackStore.php +++ b/plugins/Feedback/Application/Ports/FeedbackStore.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Plugins\User\Application\Ports; +namespace Plugins\Feedback\Application\Ports; -use Plugins\User\API\DTOs\ListFeedbackQuery; -use Plugins\User\Domain\Entities\FeedbackEntry; +use Plugins\Feedback\API\DTOs\ListFeedbackQuery; +use Plugins\Feedback\Domain\Entities\FeedbackEntry; /** * Internal persistence port for user feedback (DIP seam). diff --git a/plugins/Feedback/Application/Services/FeedbackService.php b/plugins/Feedback/Application/Services/FeedbackService.php index 373679a..700266a 100644 --- a/plugins/Feedback/Application/Services/FeedbackService.php +++ b/plugins/Feedback/Application/Services/FeedbackService.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Plugins\User\Application\Services; +namespace Plugins\Feedback\Application\Services; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\EventBus; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\SecurityException; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ServiceException; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; -use Plugins\User\API\DTOs\FeedbackPage; -use Plugins\User\API\DTOs\ListFeedbackQuery; -use Plugins\User\API\DTOs\SubmitFeedbackDTO; -use Plugins\User\API\IntegrationEvents\FeedbackSubmittedIntegrationEvent; -use Plugins\User\Application\Ports\FeedbackStore; -use Plugins\User\Domain\Entities\FeedbackEntry; -use Plugins\User\Domain\ValueObjects\FeedbackStatus; -use Plugins\User\Infrastructure\Audit\AuditLogger; +use Plugins\Feedback\API\DTOs\FeedbackPage; +use Plugins\Feedback\API\DTOs\ListFeedbackQuery; +use Plugins\Feedback\API\DTOs\SubmitFeedbackDTO; +use Plugins\Feedback\API\IntegrationEvents\FeedbackSubmittedIntegrationEvent; +use Plugins\Feedback\Application\Ports\FeedbackStore; +use Plugins\Feedback\Domain\Entities\FeedbackEntry; +use Plugins\Feedback\Domain\ValueObjects\FeedbackStatus; +use Plugins\Feedback\Infrastructure\Audit\AuditLogger; /** * FeedbackService — orchestrates user feedback. diff --git a/plugins/Feedback/Domain/Entities/FeedbackEntry.php b/plugins/Feedback/Domain/Entities/FeedbackEntry.php index fd84b21..c81289b 100644 --- a/plugins/Feedback/Domain/Entities/FeedbackEntry.php +++ b/plugins/Feedback/Domain/Entities/FeedbackEntry.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Plugins\User\Domain\Entities; +namespace Plugins\Feedback\Domain\Entities; -use Plugins\User\Domain\ValueObjects\FeedbackCategory; -use Plugins\User\Domain\ValueObjects\FeedbackId; -use Plugins\User\Domain\ValueObjects\FeedbackMessage; -use Plugins\User\Domain\ValueObjects\FeedbackRating; -use Plugins\User\Domain\ValueObjects\FeedbackStatus; +use Plugins\Feedback\Domain\ValueObjects\FeedbackCategory; +use Plugins\Feedback\Domain\ValueObjects\FeedbackId; +use Plugins\Feedback\Domain\ValueObjects\FeedbackMessage; +use Plugins\Feedback\Domain\ValueObjects\FeedbackRating; +use Plugins\Feedback\Domain\ValueObjects\FeedbackStatus; use Project\Support\Entity\Entity; /** diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php b/plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php index 105cfbf..62490d9 100644 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php +++ b/plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Plugins\User\Domain\ValueObjects; +namespace Plugins\Feedback\Domain\ValueObjects; /** * FeedbackCategory — the optional `category` column (whitelist). diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackId.php b/plugins/Feedback/Domain/ValueObjects/FeedbackId.php index 4f685a7..37f9358 100644 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackId.php +++ b/plugins/Feedback/Domain/ValueObjects/FeedbackId.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Plugins\User\Domain\ValueObjects; +namespace Plugins\Feedback\Domain\ValueObjects; /** * FeedbackId — the PUBLIC opaque identifier (the `feedback_id` char(36) column). diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php b/plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php index ec18e65..42cc9fb 100644 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php +++ b/plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Plugins\User\Domain\ValueObjects; +namespace Plugins\Feedback\Domain\ValueObjects; /** * FeedbackMessage — the required free-text body (the `message` column). diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackRating.php b/plugins/Feedback/Domain/ValueObjects/FeedbackRating.php index 58d9003..1861f93 100644 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackRating.php +++ b/plugins/Feedback/Domain/ValueObjects/FeedbackRating.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Plugins\User\Domain\ValueObjects; +namespace Plugins\Feedback\Domain\ValueObjects; /** * FeedbackRating — an optional 1–5 star rating (the `rating` tinyint column). diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php b/plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php index 7c19d13..264b223 100644 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php +++ b/plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Plugins\User\Domain\ValueObjects; +namespace Plugins\Feedback\Domain\ValueObjects; /** * FeedbackStatus — the triage lifecycle (`status` column). diff --git a/plugins/Feedback/Domain/ValueObjects/Ulid.php b/plugins/Feedback/Domain/ValueObjects/Ulid.php new file mode 100644 index 0000000..624197b --- /dev/null +++ b/plugins/Feedback/Domain/ValueObjects/Ulid.php @@ -0,0 +1,57 @@ + 16 base32 indices forming the random component. */ + private static array $lastRand = []; + + public static function generate(): string + { + $alphabet = self::ALPHABET; + $time = (int) (microtime(true) * 1000); + + if ($time === self::$lastTime && self::$lastRand !== []) { + for ($i = 15; $i >= 0; $i--) { + if (self::$lastRand[$i] < 31) { + self::$lastRand[$i]++; + break; + } + self::$lastRand[$i] = 0; + } + } else { + self::$lastTime = $time; + self::$lastRand = []; + for ($i = 0; $i < 16; $i++) { + self::$lastRand[$i] = random_int(0, 31); + } + } + + $t = $time; + $ulid = ''; + for ($i = 9; $i >= 0; $i--) { + $ulid = $alphabet[$t % 32] . $ulid; + $t = intdiv($t, 32); + } + foreach (self::$lastRand as $idx) { + $ulid .= $alphabet[$idx]; + } + + return $ulid; + } +} diff --git a/plugins/Feedback/Infrastructure/Audit/AuditLogger.php b/plugins/Feedback/Infrastructure/Audit/AuditLogger.php new file mode 100644 index 0000000..f5a8486 --- /dev/null +++ b/plugins/Feedback/Infrastructure/Audit/AuditLogger.php @@ -0,0 +1,106 @@ +sink = $sink ?? static fn(string $line) => error_log($line); + } + + /** @param array $context */ + public function record(string $action, array $context = []): void + { + $occurredAt = (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339); + + $entry = json_encode([ + 'source' => 'user_audit', + 'action' => $action, + 'actor' => $this->actorId, + 'context' => $context, + 'timestamp' => $occurredAt, + ], JSON_UNESCAPED_SLASHES); + + if ($entry !== false) { + ($this->sink)($entry); + } + + $this->persist($action, $context, $occurredAt); + } + + /** + * Persist to the shared `audit_log` table. Best-effort: any failure is + * swallowed (already captured in the log line) so auditing never aborts the + * audited action. `userId` in context maps to the user_id column; everything + * else is kept in the JSON `meta` column. + * + * @param array $context + */ + private function persist(string $action, array $context, string $occurredAt): void + { + if ($this->db === null) { + return; + } + + $userId = isset($context['userId']) ? (string) $context['userId'] : ($this->actorId ?: null); + $ip = isset($context['ip']) ? (string) $context['ip'] : null; + + $meta = $context; + unset($meta['userId'], $meta['ip']); + $metaJson = $meta === [] ? null : json_encode($meta, JSON_UNESCAPED_SLASHES); + + try { + $this->db->execute( + 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) + VALUES (:event_id, :user_id, :tenant_id, :action, :ip, :meta, :occurred_at)', + [ + 'event_id' => Ulid::generate(), + 'user_id' => $userId, + 'tenant_id' => ($this->tenantId ?? '') !== '' ? $this->tenantId : null, + 'action' => $action, + 'ip' => $ip, + 'meta' => $metaJson === false ? null : $metaJson, + 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + ], + ); + } catch (\Throwable) { + // Best-effort — the log line above is the durable fallback. + } + } +} diff --git a/plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php b/plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php index 0e1b6ce..1bca1fc 100644 --- a/plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php +++ b/plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Plugins\User\Infrastructure\Http\Controllers; +namespace Plugins\Feedback\Infrastructure\Http\Controllers; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; -use Plugins\User\API\DTOs\ListFeedbackQuery; -use Plugins\User\API\DTOs\SubmitFeedbackDTO; -use Plugins\User\Application\Services\FeedbackService; +use Plugins\Feedback\API\DTOs\ListFeedbackQuery; +use Plugins\Feedback\API\DTOs\SubmitFeedbackDTO; +use Plugins\Feedback\Application\Services\FeedbackService; use Project\Http\Controllers\ApiController; /** diff --git a/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php b/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php index 3e39531..8453dfa 100644 --- a/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php +++ b/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Plugins\User\Infrastructure\Persistence; +namespace Plugins\Feedback\Infrastructure\Persistence; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\User\API\DTOs\ListFeedbackQuery; -use Plugins\User\Application\Ports\FeedbackStore; -use Plugins\User\Domain\Entities\FeedbackEntry; +use Plugins\Feedback\API\DTOs\ListFeedbackQuery; +use Plugins\Feedback\Application\Ports\FeedbackStore; +use Plugins\Feedback\Domain\Entities\FeedbackEntry; /** * FeedbackRepository — DatabasePort ONLY. diff --git a/plugins/Feedback/Provider.php b/plugins/Feedback/Provider.php new file mode 100644 index 0000000..3302efa --- /dev/null +++ b/plugins/Feedback/Provider.php @@ -0,0 +1,95 @@ + */ + public function requires(): array + { + return [ + DatabaseConnectionManagerContract::class, + ]; + } + + /** @return list */ + public function exposes(): array + { + // Feedback is consumed only through its own HTTP routes — no published contract. + return []; + } + + public function register(ModuleContainer $container): void + { + // Feedback rows are TENANT-scoped → repository takes the request's + // tenant-routed DatabasePort directly (NOT the central connection). + $container->bindInternal(FeedbackRepository::class, static fn(ModuleContainer $c) => + new FeedbackRepository($c->make(DatabasePort::class))); + + // Audit persists to the shared CENTRAL `audit_log` table + a log line. + // The active tenant is published by Tenancy's TenantContextStage under + // the 'tenant.current' container key (a plain string — no Tenancy import). + $container->bindInternal(AuditLogger::class, static function (ModuleContainer $c) { + $identity = $c->make(Identity::class); + $tenantId = $c->has('tenant.current') ? (string) $c->make('tenant.current') : null; + return new AuditLogger( + $identity->userId ?: null, + db: self::central($c), + tenantId: $tenantId, + ); + }); + + $container->bindInternal(FeedbackService::class, static fn(ModuleContainer $c) => + new FeedbackService( + repository: $c->make(FeedbackRepository::class), + eventBus: $c->make(EventBus::class), + identity: $c->make(Identity::class), + audit: $c->make(AuditLogger::class), + )); + + $container->bindInternal(FeedbackController::class, static fn(ModuleContainer $c) => + new FeedbackController($c->make(FeedbackService::class))); + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + // No pipeline hooks or subscriptions — routes carry the wiring. + } + + /** The CENTRAL connection (owns the shared `audit_log` table). */ + private static function central(ModuleContainer $c): DatabasePort + { + return $c->make(DatabaseConnectionManagerContract::class)->default(); + } +} diff --git a/plugins/Feedback/README.md b/plugins/Feedback/README.md new file mode 100644 index 0000000..721258e --- /dev/null +++ b/plugins/Feedback/README.md @@ -0,0 +1,49 @@ +# Feedback Plugin + +> Solves: **`feedback.management`** · Namespace: **`Plugins\Feedback\`** · Type: on-demand GDA module + +Owns the **feedback.management** domain — users submit categorised, rated +feedback; admins triage it. **Extracted from the User plugin** so each plugin +owns exactly one domain (the framework's "one module, one domain" rule). + +## Data & security + +- Feedback rows live in the request's **TENANT** database (`user_feedback` + table, shipped as a tenant-template migration). The repository is bound to the + tenant-routed `DatabasePort`. +- The submitter id is taken from the authenticated **Identity**, never the body. +- Reading one entry is self-or-admin; listing/triage requires the + `feedback:manage` permission. +- The `feedback.submitted` integration event is dispatched only **after** the + write succeeds. Security-relevant actions are audited to the shared central + `audit_log` table. + +## Routes + +| Method | Path | Action | Filters | +|---|---|---|---| +| POST | `/ajx/feedback` | `submit` | `auth, tenant, throttle:5,1` | +| GET | `/ajx/feedback` | `index` (triage) | `auth, tenant` | +| GET | `/ajx/feedback/{id}` | `show` | `auth, tenant` | +| PATCH | `/ajx/feedback/{id}` | `updateStatus` | `auth, tenant` | + +## Layout + +``` +API/DTOs, API/IntegrationEvents — SubmitFeedbackDTO, ListFeedbackQuery, FeedbackPage, FeedbackSubmittedIntegrationEvent +Application/Ports/FeedbackStore — persistence seam (DIP) +Application/Services/FeedbackService — authorization + orchestration +Domain/Entities/FeedbackEntry — aggregate; VOs: FeedbackId/Category/Rating/Status/Message +Infrastructure/Persistence — FeedbackRepository (DatabasePort only) +Infrastructure/Http/Controllers — FeedbackController (thin) +Infrastructure/Audit + Domain/Ulid — self-contained copies so the plugin has zero cross-plugin dependency +``` + +## Enabling + +Add `Plugins\Feedback\Provider::class` to the project's `withModules([...])` and +run `hkm plugins enable Feedback` to publish the `user_feedback` tenant +migration. `requires: ["database.management"]`. + +> Note: the User plugin no longer serves feedback. If you want a server-rendered +> feedback demo page, build it against the `/ajx/feedback` API in your frontend. diff --git a/plugins/Feedback/module.json b/plugins/Feedback/module.json new file mode 100644 index 0000000..eec7cdd --- /dev/null +++ b/plugins/Feedback/module.json @@ -0,0 +1,23 @@ +{ + "name": "feedback", + "version": "1.0.0", + "solves": "feedback.management", + "type": "module", + + "requires": ["database.management"], + "exposes": [], + + "routes": [ + { "method": "POST", "path": "/ajx/feedback", "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@submit", "filters": ["auth", "tenant", "throttle:5,1"] }, + { "method": "GET", "path": "/ajx/feedback", "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@index", "filters": ["auth", "tenant"] }, + { "method": "GET", "path": "/ajx/feedback/{id}", "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@show", "filters": ["auth", "tenant"] }, + { "method": "PATCH", "path": "/ajx/feedback/{id}", "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@updateStatus", "filters": ["auth", "tenant"] } + ], + + "emits": ["feedback.submitted"], + "listens": [], + + "documentation": "The Feedback plugin — owns the feedback.management domain (extracted from the User plugin so each plugin owns one domain). Users submit categorised, rated feedback attributed to their authenticated Identity; admins triage it (feedback:manage). Rows live in the request's TENANT database (repository bound to the tenant-routed DatabasePort); the integration event feedback.submitted is dispatched only AFTER the write succeeds. Security-relevant actions are audited to the shared central audit_log table. Enabling publishes database/ (the user_feedback tenant-template migration).", + + "config": [] +} diff --git a/plugins/Mail/API/Contracts/MailerContract.php b/plugins/Mail/API/Contracts/MailerContract.php new file mode 100644 index 0000000..1c6d5ff --- /dev/null +++ b/plugins/Mail/API/Contracts/MailerContract.php @@ -0,0 +1,28 @@ +data(); + $mime = (string) ($data['mime'] ?? ''); + $from = (string) ($data['from'] ?? ''); + /** @var list $recipients */ + $recipients = array_values((array) ($data['recipients'] ?? [])); + + if ($mime === '' || $from === '' || $recipients === []) { + return JobResult::skipped('Malformed mail payload.'); + } + + $this->transport->send($from, $recipients, $mime); + + return JobResult::success(['recipients' => count($recipients)]); + } + + public function failed(JobPayload $payload, \Throwable $e): void + { + error_log('[mail] permanent delivery failure: ' . $e->getMessage()); + } +} diff --git a/plugins/Mail/Application/Mailer.php b/plugins/Mail/Application/Mailer.php new file mode 100644 index 0000000..9249e01 --- /dev/null +++ b/plugins/Mail/Application/Mailer.php @@ -0,0 +1,155 @@ +charset($this->charset); + if ($this->fromEmail !== '') { + $m->from($this->fromEmail, $this->fromName); + } + return $m; + } + + public function dispatch(Message $message): void + { + $compiled = $this->compile($message); + $this->transport->send($compiled['from'], $compiled['recipients'], $compiled['mime']); + } + + public function enqueue(Message $message): string + { + $compiled = $this->compile($message); + + if ($this->queue === null) { + $this->transport->send($compiled['from'], $compiled['recipients'], $compiled['mime']); + return ''; + } + + return $this->queue->push(self::QUEUE_JOB, $compiled, $this->queueName); + } + + // ── MailPort (kernel, view-based) ──────────────────────────────────────── + + /** @param string|array $to */ + public function send(string|array $to, string $subject, string $view, array $data = []): void + { + $this->dispatch($this->fromView($to, $subject, $view, $data)); + } + + /** @param string|array $to */ + public function queue(string|array $to, string $subject, string $view, array $data = []): string + { + return $this->enqueue($this->fromView($to, $subject, $view, $data)); + } + + // ── internals ──────────────────────────────────────────────────────────── + + /** @return array{from: string, recipients: list, mime: string} */ + private function compile(Message $message): array + { + if ($message->getFrom() === null) { + if ($this->fromEmail === '') { + throw new MailException('No From address on the message and no default configured.'); + } + $message->from($this->fromEmail, $this->fromName); + } + + $built = $this->mime->build($message); + $headers = $built['headers']; + $body = $built['body']; + + if ($this->dkim !== null) { + array_unshift($headers, $this->dkim->sign($headers, $body)); + } + + /** @var \Plugins\Mail\Domain\Address $from */ + $from = $message->getFrom(); + $envelope = $message->getReturnPath() ?? $message->getSender()?->email ?? $from->email; + + return [ + 'from' => $envelope, + 'recipients' => $message->recipientEmails(), + 'mime' => implode("\r\n", $headers) . "\r\n\r\n" . $body, + ]; + } + + /** @param string|array $to */ + private function fromView(string|array $to, string $subject, string $view, array $data): Message + { + $message = $this->message()->subject($subject)->html($this->render($view, $data)); + + foreach ($this->normaliseRecipients($to) as $email => $name) { + $message->to($email, $name); + } + + return $message; + } + + private function render(string $view, array $data): string + { + // With the View plugin, treat $view as a template name; without it, the + // caller passed raw HTML (so MailPort works even with no renderer bound). + return $this->views !== null ? $this->views->render($view, $data) : $view; + } + + /** + * @param string|array $to + * @return array email => name + */ + private function normaliseRecipients(string|array $to): array + { + if (is_string($to)) { + return [$to => '']; + } + + $out = []; + foreach ($to as $key => $value) { + if (is_int($key)) { + $out[$value] = ''; // list of emails + } else { + $out[$key] = $value; // email => name + } + } + return $out; + } +} diff --git a/plugins/Mail/Domain/Address.php b/plugins/Mail/Domain/Address.php new file mode 100644 index 0000000..50f828f --- /dev/null +++ b/plugins/Mail/Domain/Address.php @@ -0,0 +1,54 @@ +email = $email; + $this->name = trim($name); + } + + /** RFC 5322 header form: `"Name" ` (name MIME-encoded when non-ASCII). */ + public function toHeader(string $charset = 'UTF-8'): string + { + if ($this->name === '') { + return $this->email; + } + + $name = preg_match('/[^\x20-\x7E]/', $this->name) === 1 + ? mb_encode_mimeheader($this->name, $charset, 'B', "\r\n") // chunked encoded-words + : '"' . addcslashes($this->name, '"\\') . '"'; + + return $name . ' <' . $this->email . '>'; + } + + private static function hasControlChars(string $value): bool + { + return preg_match('/[\r\n\t\x00]/', $value) === 1; + } +} diff --git a/plugins/Mail/Domain/Attachment.php b/plugins/Mail/Domain/Attachment.php new file mode 100644 index 0000000..14f13a6 --- /dev/null +++ b/plugins/Mail/Domain/Attachment.php @@ -0,0 +1,85 @@ +`. + */ +final readonly class Attachment +{ + private function __construct( + public string $name, // filename shown to the recipient + public string $mimeType, + public bool $inline, + public string $cid, // Content-ID (inline only) + public ?string $path, // read at build time when set + public ?string $data, // raw bytes when path is null + ) { + if (preg_match('/[\r\n\x00]/', $name) === 1 || preg_match('/[\r\n\x00]/', $cid) === 1) { + throw new MailException('Attachment name/cid may not contain control characters.'); + } + } + + public static function fromPath(string $path, string $name = '', string $mimeType = ''): self + { + return new self( + name: $name !== '' ? $name : basename($path), + mimeType: $mimeType !== '' ? $mimeType : self::guessMime($path), + inline: false, + cid: '', + path: $path, + data: null, + ); + } + + public static function fromData(string $data, string $name, string $mimeType = 'application/octet-stream'): self + { + return new self($name, $mimeType, false, '', null, $data); + } + + /** Inline image referenced from HTML via cid:. */ + public static function inline(string $pathOrData, string $cid, string $name = '', string $mimeType = '', bool $isPath = true): self + { + return new self( + name: $name !== '' ? $name : ($isPath ? basename($pathOrData) : $cid), + mimeType: $mimeType !== '' ? $mimeType : ($isPath ? self::guessMime($pathOrData) : 'application/octet-stream'), + inline: true, + cid: $cid, + path: $isPath ? $pathOrData : null, + data: $isPath ? null : $pathOrData, + ); + } + + /** Resolve the raw bytes (reads the file when path-backed). */ + public function contents(): string + { + if ($this->data !== null) { + return $this->data; + } + if ($this->path === null || is_file($this->path) === false || is_readable($this->path) === false) { + throw new MailException("Attachment not readable: {$this->path}"); + } + $bytes = file_get_contents($this->path); + if ($bytes === false) { + throw new MailException("Failed to read attachment: {$this->path}"); + } + return $bytes; + } + + private static function guessMime(string $path): string + { + if (function_exists('mime_content_type') && is_file($path)) { + $type = @mime_content_type($path); + if (is_string($type) && $type !== '') { + return $type; + } + } + return 'application/octet-stream'; + } +} diff --git a/plugins/Mail/Domain/MailException.php b/plugins/Mail/Domain/MailException.php new file mode 100644 index 0000000..0bd7de5 --- /dev/null +++ b/plugins/Mail/Domain/MailException.php @@ -0,0 +1,10 @@ + */ + private array $to = []; + /** @var list
*/ + private array $cc = []; + /** @var list
*/ + private array $bcc = []; + /** @var list
*/ + private array $replyTo = []; + + private string $subject = ''; + private string $html = ''; + private string $text = ''; + private string $charset = 'UTF-8'; + private Priority $priority = Priority::Normal; + private ?Address $confirmReadingTo = null; // Disposition-Notification-To + + /** @var list */ + private array $attachments = []; + /** @var array */ + private array $headers = []; + /** @var array */ + private array $metadata = []; + + public static function make(): self + { + return new self(); + } + + // ── envelope / from ────────────────────────────────────────────────────── + + public function from(string $email, string $name = ''): self + { + $this->from = new Address($email, $name); + return $this; + } + + /** Distinct envelope sender (Sender header + default Return-Path). */ + public function sender(string $email, string $name = ''): self + { + $this->sender = new Address($email, $name); + return $this; + } + + public function returnPath(string $email): self + { + $this->returnPath = (new Address($email))->email; + return $this; + } + + // ── recipients ─────────────────────────────────────────────────────────── + + public function to(string $email, string $name = ''): self + { + $this->to[] = new Address($email, $name); + return $this; + } + + public function cc(string $email, string $name = ''): self + { + $this->cc[] = new Address($email, $name); + return $this; + } + + public function bcc(string $email, string $name = ''): self + { + $this->bcc[] = new Address($email, $name); + return $this; + } + + public function replyTo(string $email, string $name = ''): self + { + $this->replyTo[] = new Address($email, $name); + return $this; + } + + // ── content ────────────────────────────────────────────────────────────── + + public function subject(string $subject): self + { + // Strip control chars — the subject becomes a header. + $this->subject = (string) preg_replace('/[\r\n\x00]/', '', $subject); + return $this; + } + + public function html(string $html): self + { + $this->html = $html; + return $this; + } + + public function text(string $text): self + { + $this->text = $text; + return $this; + } + + public function charset(string $charset): self + { + $this->charset = $charset; + return $this; + } + + public function priority(Priority $priority): self + { + $this->priority = $priority; + return $this; + } + + /** Request a read receipt to this address (Disposition-Notification-To). */ + public function confirmReadingTo(string $email, string $name = ''): self + { + $this->confirmReadingTo = new Address($email, $name); + return $this; + } + + // ── attachments ────────────────────────────────────────────────────────── + + public function attach(string $path, string $name = '', string $mimeType = ''): self + { + $this->attachments[] = Attachment::fromPath($path, $name, $mimeType); + return $this; + } + + public function attachData(string $data, string $name, string $mimeType = 'application/octet-stream'): self + { + $this->attachments[] = Attachment::fromData($data, $name, $mimeType); + return $this; + } + + /** Embed an image and reference it in HTML as ``. */ + public function embed(string $path, string $cid, string $name = '', string $mimeType = ''): self + { + $this->attachments[] = Attachment::inline($path, $cid, $name, $mimeType, isPath: true); + return $this; + } + + public function embedData(string $data, string $cid, string $name = '', string $mimeType = 'application/octet-stream'): self + { + $this->attachments[] = Attachment::inline($data, $cid, $name, $mimeType, isPath: false); + return $this; + } + + // ── headers / metadata ─────────────────────────────────────────────────── + + public function header(string $name, string $value): self + { + if (preg_match('/[\r\n\x00]/', $name . $value) === 1) { + throw new MailException('Custom headers may not contain control characters.'); + } + $this->headers[$name] = $value; + return $this; + } + + /** Arbitrary tag for logging / webhooks (not sent unless you also add a header). */ + public function tag(string $key, string|int|float|bool $value): self + { + $this->metadata[$key] = $value; + return $this; + } + + // ── accessors (used by the MIME builder / transports) ──────────────────── + + public function getFrom(): ?Address { return $this->from; } + public function getSender(): ?Address { return $this->sender; } + public function getReturnPath(): ?string { return $this->returnPath; } + /** @return list
*/ public function getTo(): array { return $this->to; } + /** @return list
*/ public function getCc(): array { return $this->cc; } + /** @return list
*/ public function getBcc(): array { return $this->bcc; } + /** @return list
*/ public function getReplyTo(): array { return $this->replyTo; } + public function getSubject(): string { return $this->subject; } + public function getHtml(): string { return $this->html; } + public function getText(): string { return $this->text; } + public function getCharset(): string { return $this->charset; } + public function getPriority(): Priority { return $this->priority; } + public function getConfirmReadingTo(): ?Address { return $this->confirmReadingTo; } + /** @return list */ public function getAttachments(): array { return $this->attachments; } + /** @return array */ public function getHeaders(): array { return $this->headers; } + /** @return array */ public function getMetadata(): array { return $this->metadata; } + + /** All RCPT recipients (to + cc + bcc) as bare addresses. @return list */ + public function recipientEmails(): array + { + $all = []; + foreach ([...$this->to, ...$this->cc, ...$this->bcc] as $a) { + $all[$a->email] = true; // dedupe + } + return array_keys($all); + } +} diff --git a/plugins/Mail/Domain/Priority.php b/plugins/Mail/Domain/Priority.php new file mode 100644 index 0000000..9fc70cc --- /dev/null +++ b/plugins/Mail/Domain/Priority.php @@ -0,0 +1,22 @@ + 'High', + self::Normal => 'Normal', + self::Low => 'Low', + }; + } +} diff --git a/plugins/Mail/Infrastructure/Mime/MimeBuilder.php b/plugins/Mail/Infrastructure/Mime/MimeBuilder.php new file mode 100644 index 0000000..44337ef --- /dev/null +++ b/plugins/Mail/Infrastructure/Mime/MimeBuilder.php @@ -0,0 +1,275 @@ +, body: string} */ + public function build(Message $message): array + { + if ($message->getFrom() === null) { + throw new MailException('A message must have a From address.'); + } + if ($message->recipientEmails() === []) { + throw new MailException('A message must have at least one recipient.'); + } + + $root = $this->contentRoot($message); + $headers = $this->topHeaders($message); + foreach ($root['headers'] as $h) { + $headers[] = $h; // Content-Type / -Transfer-Encoding of the root part + } + + // Fold every header so no line exceeds the RFC 5322 limits — a long + // To/Cc list or a long Subject would otherwise be rejected by strict MTAs. + $headers = array_map($this->foldHeader(...), $headers); + + return ['headers' => $headers, 'body' => $root['body']]; + } + + // ── content tree ───────────────────────────────────────────────────────── + + /** @return array{headers: list, body: string} */ + private function contentRoot(Message $m): array + { + $charset = $m->getCharset(); + $html = $m->getHtml(); + $text = $m->getText(); + + if ($html !== '' && $text === '') { + $text = $this->htmlToText($html); // always ship a plain-text alternative + } + + if ($html !== '' && $text !== '') { + $content = $this->multipart('alternative', [ + $this->textPart($text, 'text/plain', $charset), + $this->textPart($html, 'text/html', $charset), + ]); + } elseif ($html !== '') { + $content = $this->textPart($html, 'text/html', $charset); + } else { + $content = $this->textPart($text, 'text/plain', $charset); + } + + $inline = array_values(array_filter($m->getAttachments(), static fn(Attachment $a): bool => $a->inline)); + $regular = array_values(array_filter($m->getAttachments(), static fn(Attachment $a): bool => !$a->inline)); + + if ($inline !== []) { + $rootType = $html !== '' ? 'text/html' : 'text/plain'; + $content = $this->multipart( + 'related', + [$content, ...array_map($this->attachmentPart(...), $inline)], + '; type="' . $rootType . '"', + ); + } + + if ($regular !== []) { + $content = $this->multipart( + 'mixed', + [$content, ...array_map($this->attachmentPart(...), $regular)], + ); + } + + return $content; + } + + // ── leaf parts ─────────────────────────────────────────────────────────── + + /** @return array{headers: list, body: string} */ + private function textPart(string $body, string $type, string $charset): array + { + return [ + 'headers' => [ + 'Content-Type: ' . $type . '; charset=' . $charset, + 'Content-Transfer-Encoding: quoted-printable', + ], + 'body' => $this->quotedPrintable($body), + ]; + } + + /** @return array{headers: list, body: string} */ + private function attachmentPart(Attachment $a): array + { + $headers = [ + 'Content-Type: ' . $a->mimeType . '; name="' . $this->headerParam($a->name) . '"', + 'Content-Transfer-Encoding: base64', + ]; + if ($a->inline) { + $headers[] = 'Content-Disposition: inline; filename="' . $this->headerParam($a->name) . '"'; + $headers[] = 'Content-ID: <' . $a->cid . '>'; + } else { + $headers[] = 'Content-Disposition: attachment; filename="' . $this->headerParam($a->name) . '"'; + } + + return [ + 'headers' => $headers, + 'body' => rtrim(chunk_split(base64_encode($a->contents()), 76, self::EOL), self::EOL), + ]; + } + + // ── multipart composition ──────────────────────────────────────────────── + + /** + * @param list, body: string}> $children + * @return array{headers: list, body: string} + */ + private function multipart(string $subtype, array $children, string $typeParams = ''): array + { + $boundary = 'b1_' . bin2hex(random_bytes(16)); + + $body = ''; + foreach ($children as $child) { + $body .= '--' . $boundary . self::EOL + . implode(self::EOL, $child['headers']) . self::EOL . self::EOL + . $child['body'] . self::EOL; + } + $body .= '--' . $boundary . '--' . self::EOL; + + return [ + 'headers' => ['Content-Type: multipart/' . $subtype . '; boundary="' . $boundary . '"' . $typeParams], + 'body' => $body, + ]; + } + + // ── top-level headers ──────────────────────────────────────────────────── + + /** @return list */ + private function topHeaders(Message $m): array + { + /** @var \Plugins\Mail\Domain\Address $from */ + $from = $m->getFrom(); + $charset = $m->getCharset(); + $headers = []; + + $headers[] = 'Date: ' . date('r'); + $headers[] = 'From: ' . $from->toHeader($charset); + if ($m->getSender() !== null) { + $headers[] = 'Sender: ' . $m->getSender()->toHeader($charset); + } + if ($m->getTo() !== []) { + $headers[] = 'To: ' . $this->addressList($m->getTo(), $charset); + } + if ($m->getCc() !== []) { + $headers[] = 'Cc: ' . $this->addressList($m->getCc(), $charset); + } + // Bcc is deliberately NOT emitted as a header — recipients stay hidden. + if ($m->getReplyTo() !== []) { + $headers[] = 'Reply-To: ' . $this->addressList($m->getReplyTo(), $charset); + } + if ($m->getConfirmReadingTo() !== null) { + $headers[] = 'Disposition-Notification-To: ' . $m->getConfirmReadingTo()->toHeader($charset); + } + + $headers[] = 'Subject: ' . $this->encodeHeaderText($m->getSubject(), $charset); + $headers[] = 'Message-ID: <' . bin2hex(random_bytes(16)) . '@' . $this->hostOf($from->email) . '>'; + $headers[] = 'X-Priority: ' . $m->getPriority()->value . ' (' . $m->getPriority()->label() . ')'; + $headers[] = 'X-Mailer: HKM-Mail'; + $headers[] = 'MIME-Version: 1.0'; + + foreach ($m->getHeaders() as $name => $value) { + $headers[] = $name . ': ' . $value; + } + + return $headers; + } + + /** @param list<\Plugins\Mail\Domain\Address> $addresses */ + private function addressList(array $addresses, string $charset): string + { + return implode(', ', array_map(static fn($a): string => $a->toHeader($charset), $addresses)); + } + + // ── encoders ───────────────────────────────────────────────────────────── + + private function quotedPrintable(string $text): string + { + // Normalise to CRLF, then QP-encode (PHP inserts =\r\n soft breaks). + $text = str_replace(["\r\n", "\r", "\n"], "\n", $text); + $text = str_replace("\n", self::EOL, $text); + + return quoted_printable_encode($text); + } + + /** + * RFC 2047 encoded-word for non-ASCII header text (Subject, etc.). Uses + * mb_encode_mimeheader so long values are split into MULTIPLE ≤75-char + * encoded-words (a single oversized encoded-word is non-conformant and can + * be mangled by receivers). + */ + private function encodeHeaderText(string $text, string $charset): string + { + if (preg_match('/[^\x20-\x7E]/', $text) !== 1) { + return $text; + } + return mb_encode_mimeheader($text, $charset, 'B', self::EOL); + } + + /** + * Fold a completed header line at whitespace so no line exceeds 78 chars + * (soft; the RFC 5322 hard limit is 998). Only existing whitespace is used as + * a fold point (RFC 5322 §2.2.3), so encoded-words and e-mail addresses — + * which contain no spaces — are never split. + */ + private function foldHeader(string $line, int $limit = 78): string + { + // Already-folded (mb_encode_mimeheader) or short lines pass through. + if (strpos($line, self::EOL) !== false || strlen($line) <= $limit) { + return $line; + } + + $out = ''; + $current = ''; + foreach (preg_split('/( )/', $line, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$line] as $token) { + if ($current !== '' && trim($current) !== '' && strlen($current . $token) > $limit) { + $out .= rtrim($current, ' ') . self::EOL . ' '; + $current = ltrim($token, ' '); + } else { + $current .= $token; + } + } + + return $out . $current; + } + + /** Strip CR/LF/quotes from a header parameter (filename). */ + private function headerParam(string $value): string + { + return (string) preg_replace('/[\r\n"\x00]/', '', $value); + } + + private function hostOf(string $email): string + { + $at = strrpos($email, '@'); + return $at === false ? 'localhost' : substr($email, $at + 1); + } + + private function htmlToText(string $html): string + { + $text = preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', $html) ?? $html; + $text = preg_replace('//i', "\n", $text) ?? $text; + $text = preg_replace('/<\/(p|div|h[1-6]|li|tr)>/i', "\n", $text) ?? $text; + + return trim(html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } +} diff --git a/plugins/Mail/Infrastructure/Security/DkimSigner.php b/plugins/Mail/Infrastructure/Security/DkimSigner.php new file mode 100644 index 0000000..7591e1e --- /dev/null +++ b/plugins/Mail/Infrastructure/Security/DkimSigner.php @@ -0,0 +1,106 @@ +._domainkey.` TXT. + */ +final readonly class DkimSigner +{ + /** @param list $signedHeaders lower-case header names to sign when present */ + public function __construct( + private string $domain, + private string $selector, + private string $privateKeyPem, + private array $signedHeaders = ['from', 'to', 'cc', 'subject', 'date', 'message-id', 'mime-version', 'content-type'], + ) {} + + /** + * @param list $headers "Name: value" lines + * @return string the DKIM-Signature header line (no trailing CRLF) + */ + public function sign(array $headers, string $body): string + { + $key = openssl_pkey_get_private($this->privateKeyPem); + if ($key === false) { + throw new MailException('DKIM: invalid private key.'); + } + + $bodyHash = base64_encode(hash('sha256', $this->canonicalizeBody($body), true)); + + // Collect the signed headers (last occurrence, in configured order). + $index = $this->indexHeaders($headers); + $names = []; + $canonHeaders = []; + foreach ($this->signedHeaders as $name) { + if (isset($index[$name])) { + $names[] = $name; + $canonHeaders[] = $this->canonicalizeHeader($name, $index[$name]); + } + } + + $dkim = 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=' . $this->domain + . '; s=' . $this->selector + . '; t=' . time() + . '; h=' . implode(':', $names) + . '; bh=' . $bodyHash + . '; b='; + + // The DKIM-Signature header itself is signed with an empty b= and NO CRLF. + $canonHeaders[] = $this->canonicalizeHeader('dkim-signature', $dkim); + $toSign = implode("\r\n", $canonHeaders); + + $signature = ''; + if (openssl_sign($toSign, $signature, $key, OPENSSL_ALGO_SHA256) === false) { + throw new MailException('DKIM: signing failed.'); + } + + return 'DKIM-Signature: ' . $dkim . base64_encode($signature); + } + + /** @param list $headers @return array lower-name => value (last wins) */ + private function indexHeaders(array $headers): array + { + $index = []; + foreach ($headers as $line) { + $pos = strpos($line, ':'); + if ($pos === false) { + continue; + } + $index[strtolower(trim(substr($line, 0, $pos)))] = ltrim(substr($line, $pos + 1)); + } + return $index; + } + + /** Relaxed header canonicalization: lower name, unfold, collapse WSP, trim. */ + private function canonicalizeHeader(string $name, string $value): string + { + $value = preg_replace('/\s+/', ' ', str_replace(["\r\n", "\r", "\n"], ' ', $value)) ?? $value; + + return $name . ':' . trim($value); + } + + /** Relaxed body canonicalization: strip trailing WSP, collapse WSP, drop trailing blank lines. */ + private function canonicalizeBody(string $body): string + { + $body = str_replace(["\r\n", "\r", "\n"], "\n", $body); + $lines = explode("\n", $body); + $lines = array_map( + static fn(string $l): string => rtrim((string) preg_replace('/[ \t]+/', ' ', $l)), + $lines, + ); + $canonical = implode("\r\n", $lines); + $canonical = rtrim($canonical, "\r\n"); + + return $canonical === '' ? '' : $canonical . "\r\n"; + } +} diff --git a/plugins/Mail/Infrastructure/Transport/ArrayTransport.php b/plugins/Mail/Infrastructure/Transport/ArrayTransport.php new file mode 100644 index 0000000..d9649e1 --- /dev/null +++ b/plugins/Mail/Infrastructure/Transport/ArrayTransport.php @@ -0,0 +1,42 @@ +, mime: string}> */ + private array $sent = []; + + public function send(string $envelopeFrom, array $recipients, string $mime): void + { + $this->sent[] = ['from' => $envelopeFrom, 'recipients' => $recipients, 'mime' => $mime]; + } + + /** @return list, mime: string}> */ + public function messages(): array + { + return $this->sent; + } + + /** @return array{from: string, recipients: list, mime: string}|null */ + public function last(): ?array + { + return $this->sent[array_key_last($this->sent)] ?? null; + } + + public function count(): int + { + return count($this->sent); + } + + public function flush(): void + { + $this->sent = []; + } +} diff --git a/plugins/Mail/Infrastructure/Transport/LogTransport.php b/plugins/Mail/Infrastructure/Transport/LogTransport.php new file mode 100644 index 0000000..c47375f --- /dev/null +++ b/plugins/Mail/Infrastructure/Transport/LogTransport.php @@ -0,0 +1,27 @@ +sink ??= static fn(string $line): bool => error_log($line); + } + + public function send(string $envelopeFrom, array $recipients, string $mime): void + { + $entry = '[mail] from=' . $envelopeFrom + . ' to=' . implode(',', $recipients) . "\n" . $mime; + + ($this->sink)($entry); + } +} diff --git a/plugins/Mail/Infrastructure/Transport/MailTransport.php b/plugins/Mail/Infrastructure/Transport/MailTransport.php new file mode 100644 index 0000000..92affae --- /dev/null +++ b/plugins/Mail/Infrastructure/Transport/MailTransport.php @@ -0,0 +1,52 @@ +parseHeaders($headerBlock); + $to = $headers['to'] ?? implode(', ', $recipients); + $subject = $headers['subject'] ?? ''; + + // mail() takes To + Subject separately; remove them from the header blob. + $remaining = preg_replace('/^(To|Subject):.*(\r\n|$)/mi', '', $headerBlock) ?? $headerBlock; + + $ok = mail($to, $subject, $body, trim($remaining), '-f' . $envelopeFrom); + if ($ok === false) { + throw new MailException('mail(): delivery failed.'); + } + } + + /** @return array lower-name => value (first line only) */ + private function parseHeaders(string $block): array + { + $out = []; + foreach (explode("\r\n", $block) as $line) { + $pos = strpos($line, ':'); + if ($pos !== false && $line[0] !== ' ' && $line[0] !== "\t") { + $out[strtolower(substr($line, 0, $pos))] = ltrim(substr($line, $pos + 1)); + } + } + return $out; + } +} diff --git a/plugins/Mail/Infrastructure/Transport/SendmailTransport.php b/plugins/Mail/Infrastructure/Transport/SendmailTransport.php new file mode 100644 index 0000000..90c2e3b --- /dev/null +++ b/plugins/Mail/Infrastructure/Transport/SendmailTransport.php @@ -0,0 +1,52 @@ +assertSafe($envelopeFrom); + foreach ($recipients as $r) { + $this->assertSafe($r); + } + + $cmd = escapeshellcmd($this->binary) . ' -oi' + . ' -f ' . escapeshellarg($envelopeFrom) . ' ' + . implode(' ', array_map('escapeshellarg', $recipients)); + + $process = @proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + throw new MailException('Sendmail: failed to start ' . $this->binary); + } + + fwrite($pipes[0], str_replace(["\r\n", "\r", "\n"], "\r\n", $mime)); + fclose($pipes[0]); + fclose($pipes[1]); + fclose($pipes[2]); + + if (proc_close($process) !== 0) { + throw new MailException('Sendmail: delivery returned a non-zero status.'); + } + } + + private function assertSafe(string $address): void + { + if (preg_match('/[\r\n\x00]/', $address) === 1) { + throw new MailException('Sendmail: address contains control characters.'); + } + } +} diff --git a/plugins/Mail/Infrastructure/Transport/SmtpTransport.php b/plugins/Mail/Infrastructure/Transport/SmtpTransport.php new file mode 100644 index 0000000..bb72097 --- /dev/null +++ b/plugins/Mail/Infrastructure/Transport/SmtpTransport.php @@ -0,0 +1,288 @@ + $hosts ordered failover list + * @param 'tls'|'ssl'|'none' $encryption + * @param 'auto'|'plain'|'login'|'cram-md5'|'xoauth2'|'none' $authMode + */ + public function __construct( + private readonly array $hosts, + private readonly int $port = 587, + private readonly string $encryption = 'tls', + private readonly string $username = '', + private readonly string $password = '', + private readonly string $authMode = 'auto', + private readonly string $oauthToken = '', + private readonly string $heloDomain = '', + private readonly int $timeout = 30, + private readonly bool $verifyPeer = true, + private readonly bool $keepAlive = false, + /** Allow AUTH over a plaintext channel — DANGEROUS, off by default. */ + private readonly bool $allowInsecureAuth = false, + ) {} + + public function send(string $envelopeFrom, array $recipients, string $mime): void + { + $this->assertNoInjection($envelopeFrom); + foreach ($recipients as $rcpt) { + $this->assertNoInjection($rcpt); + } + + if ($this->socket === null) { + $this->connect(); + } + + try { + $this->command('MAIL FROM:<' . $envelopeFrom . '>', 250); + foreach ($recipients as $rcpt) { + $this->command('RCPT TO:<' . $rcpt . '>', 250); + } + $this->command('DATA', 354); + $this->write($this->dotStuff($mime) . self::EOL . '.'); + $this->expect(250); + } catch (\Throwable $e) { + $this->close(); + throw $e; + } + + if ($this->keepAlive) { + $this->command('RSET', 250); + } else { + $this->close(); + } + } + + // ── connection / handshake ─────────────────────────────────────────────── + + private function connect(): void + { + $lastError = 'no hosts configured'; + + foreach ($this->hosts as $host) { + try { + $this->open($host); + $this->handshake(); + return; + } catch (\Throwable $e) { + $lastError = $host . ': ' . $e->getMessage(); + $this->close(); + } + } + + throw new MailException('SMTP: could not connect (' . $lastError . ').'); + } + + private function open(string $host): void + { + $this->secured = false; + $scheme = $this->encryption === 'ssl' ? 'ssl://' : 'tcp://'; + $context = stream_context_create(['ssl' => [ + 'verify_peer' => $this->verifyPeer, + 'verify_peer_name' => $this->verifyPeer, + 'allow_self_signed' => !$this->verifyPeer, + 'SNI_enabled' => true, + 'peer_name' => $host, + ]]); + + $socket = @stream_socket_client( + $scheme . $host . ':' . $this->port, + $errno, + $errstr, + (float) $this->timeout, + STREAM_CLIENT_CONNECT, + $context, + ); + if ($socket === false) { + throw new MailException("SMTP connect failed: {$errstr} ({$errno})"); + } + + stream_set_timeout($socket, $this->timeout); + $this->socket = $socket; + $this->secured = $this->encryption === 'ssl'; // implicit TLS + $this->expect(220); // server greeting + } + + private function handshake(): void + { + $helo = $this->heloDomain !== '' ? $this->heloDomain : (gethostname() ?: 'localhost'); + + $ehlo = $this->ehlo($helo); + if ($this->encryption === 'tls') { + // Fail CLOSED: if the server does not advertise STARTTLS we refuse + // rather than silently continue in plaintext (downgrade protection). + if (stripos($ehlo, 'STARTTLS') === false) { + throw new MailException('SMTP: server did not offer STARTTLS; refusing to continue unencrypted.'); + } + $this->command('STARTTLS', 220); + $crypto = @stream_socket_enable_crypto( + $this->socket, + true, + STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + ); + if ($crypto !== true) { + throw new MailException('SMTP: STARTTLS negotiation failed.'); + } + $this->secured = true; + $ehlo = $this->ehlo($helo); // re-EHLO over the encrypted channel + } + + if ($this->authMode !== 'none' && ($this->username !== '' || $this->oauthToken !== '')) { + // NEVER put credentials on the wire in cleartext unless explicitly forced. + if (!$this->secured && !$this->allowInsecureAuth) { + throw new MailException('SMTP: refusing to send credentials over an unencrypted connection (enable TLS or allow_insecure_auth).'); + } + $this->authenticate($ehlo); + } + } + + /** @return string the raw EHLO response (capability lines) */ + private function ehlo(string $helo): string + { + return $this->command('EHLO ' . $helo, 250); + } + + // ── auth ───────────────────────────────────────────────────────────────── + + private function authenticate(string $ehlo): void + { + $mode = $this->authMode === 'auto' ? $this->negotiateAuth($ehlo) : $this->authMode; + + match ($mode) { + 'xoauth2' => $this->authXoauth2(), + 'login' => $this->authLogin(), + 'cram-md5' => $this->authCramMd5(), + default => $this->authPlain(), + }; + } + + private function negotiateAuth(string $ehlo): string + { + $caps = strtoupper($ehlo); + return match (true) { + $this->oauthToken !== '' && str_contains($caps, 'XOAUTH2') => 'xoauth2', + str_contains($caps, 'CRAM-MD5') => 'cram-md5', + str_contains($caps, 'LOGIN') => 'login', + default => 'plain', + }; + } + + private function authPlain(): void + { + $token = base64_encode("\0" . $this->username . "\0" . $this->password); + $this->command('AUTH PLAIN ' . $token, 235); + } + + private function authLogin(): void + { + $this->command('AUTH LOGIN', 334); + $this->command(base64_encode($this->username), 334); + $this->command(base64_encode($this->password), 235); + } + + private function authCramMd5(): void + { + $challenge = $this->command('AUTH CRAM-MD5', 334); + $decoded = base64_decode(trim(substr($challenge, 4)), true) ?: ''; + $digest = hash_hmac('md5', $decoded, $this->password); + $this->command(base64_encode($this->username . ' ' . $digest), 235); + } + + private function authXoauth2(): void + { + $token = base64_encode( + 'user=' . $this->username . "\x01auth=Bearer " . $this->oauthToken . "\x01\x01", + ); + $this->command('AUTH XOAUTH2 ' . $token, 235); + } + + // ── protocol I/O ───────────────────────────────────────────────────────── + + private function command(string $command, int $expected): string + { + $this->write($command); + return $this->expect($expected); + } + + private function write(string $line): void + { + if ($this->socket === null || fwrite($this->socket, $line . self::EOL) === false) { + throw new MailException('SMTP: write failed.'); + } + } + + private function expect(int $code): string + { + $response = ''; + while (($line = fgets($this->socket ?: null, 515)) !== false) { + $response .= $line; + // Multi-line replies use "250-", the final line uses "250 ". + if (strlen($line) < 4 || $line[3] === ' ') { + break; + } + } + + $status = (int) substr($response, 0, 3); + if ($status !== $code) { + throw new MailException('SMTP: expected ' . $code . ', got: ' . trim($response)); + } + + return $response; + } + + /** SMTP dot-stuffing: a line starting with '.' gets an extra '.'. */ + private function dotStuff(string $mime): string + { + $mime = str_replace(["\r\n", "\r", "\n"], self::EOL, $mime); + return (string) preg_replace('/^\./m', '..', $mime); + } + + private function assertNoInjection(string $address): void + { + if (preg_match('/[\r\n\x00]/', $address) === 1) { + throw new MailException('SMTP: address contains illegal control characters.'); + } + } + + private function close(): void + { + if (is_resource($this->socket)) { + @fwrite($this->socket, 'QUIT' . self::EOL); + @fclose($this->socket); + } + $this->socket = null; + } + + public function __destruct() + { + $this->close(); + } +} diff --git a/plugins/Mail/Infrastructure/Transport/Transport.php b/plugins/Mail/Infrastructure/Transport/Transport.php new file mode 100644 index 0000000..320fc34 --- /dev/null +++ b/plugins/Mail/Infrastructure/Transport/Transport.php @@ -0,0 +1,22 @@ + $recipients bare addresses for RCPT TO (to+cc+bcc) + * @param string $mime full message (headers + CRLFCRLF + body) + */ + public function send(string $envelopeFrom, array $recipients, string $mime): void; +} diff --git a/plugins/Mail/Provider.php b/plugins/Mail/Provider.php index 913e632..7a2f6d5 100644 --- a/plugins/Mail/Provider.php +++ b/plugins/Mail/Provider.php @@ -4,28 +4,39 @@ namespace Plugins\Mail; -use AlfacodeTeam\PhpServicePlatform\Kernel\Contracts\ModuleContract; use AlfacodeTeam\PhpServicePlatform\Kernel\Container\ModuleContainer; +use AlfacodeTeam\PhpServicePlatform\Kernel\Contracts\ModuleContract; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\EventBus; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Cli\CliPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort; -use Plugins\Mail\Infrastructure\SmtpMailer; -use Plugins\Mail\Infrastructure\SmtpTransport; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\QueuePort; +use Plugins\Mail\API\Contracts\MailerContract; +use Plugins\Mail\Application\Jobs\SendMailJob; +use Plugins\Mail\Application\Mailer; +use Plugins\Mail\Infrastructure\Mime\MimeBuilder; +use Plugins\Mail\Infrastructure\Security\DkimSigner; +use Plugins\Mail\Infrastructure\Transport\ArrayTransport; +use Plugins\Mail\Infrastructure\Transport\LogTransport; +use Plugins\Mail\Infrastructure\Transport\MailTransport; +use Plugins\Mail\Infrastructure\Transport\SendmailTransport; +use Plugins\Mail\Infrastructure\Transport\SmtpTransport; +use Plugins\Mail\Infrastructure\Transport\Transport; +use Plugins\View\API\Contracts\ViewRendererContract; /** - * Mail plugin — SMTP adapter for the kernel MailPort. + * Mail plugin — native, dependency-free mail delivery. * - * Binds MailPort to an SmtpMailer built from env, but ONLY when SMTP_HOST is - * set, so projects without mail configured boot unaffected (the kernel/project - * may bind its own MailPort otherwise). + * Binds the Transport (from config), the MimeBuilder, an optional DkimSigner and + * the Mailer — which satisfies BOTH the kernel `MailPort` (so any module's + * view-based `send()`/`queue()` just works) and the richer `MailerContract`. */ final class Provider implements ModuleContract { public function solves(): string { - return 'mail.smtp'; + return 'mail.delivery'; } /** @return list */ @@ -37,35 +48,101 @@ public function requires(): array /** @return list */ public function exposes(): array { - return [MailPort::class]; + return [MailPort::class, MailerContract::class]; } public function register(ModuleContainer $container): void { - $host = env('SMTP_HOST') ?: ''; - if ($host === '' || $container->has(MailPort::class)) { - return; // not configured, or a project already provided MailPort - } + $config = $this->config(); - $container->bind(MailPort::class, static function () use ($host) { - $transport = new SmtpTransport( - host: $host, - port: (int) (env('SMTP_PORT') ?: 587), - username: env('SMTP_USERNAME') ?: null, - password: env('SMTP_PASSWORD') ?: null, - encryption: env('SMTP_ENCRYPTION') ?: 'tls', - ); + $container->bindInternal(Transport::class, fn(ModuleContainer $c): Transport => $this->makeTransport($config)); + + $container->bindInternal(MimeBuilder::class, static fn(): MimeBuilder => new MimeBuilder()); - return new SmtpMailer( - transport: $transport, - fromEmail: env('MAIL_FROM_ADDRESS') ?: 'no-reply@localhost', - fromName: env('MAIL_FROM_NAME') ?: '', - viewsPath: env('MAIL_VIEWS_PATH') ?: '', + $container->bind(Mailer::class, function (ModuleContainer $c) use ($config): Mailer { + return new Mailer( + transport: $c->make(Transport::class), + mime: $c->make(MimeBuilder::class), + dkim: $this->makeDkim($config), + views: $c->has(ViewRendererContract::class) ? $c->make(ViewRendererContract::class) : null, + queue: $c->has(QueuePort::class) ? $c->make(QueuePort::class) : null, + fromEmail: (string) ($config['from']['address'] ?? ''), + fromName: (string) ($config['from']['name'] ?? ''), + charset: (string) ($config['charset'] ?? 'UTF-8'), + queueName: (string) ($config['queue'] ?? 'mail'), ); }); + + // One instance satisfies MailPort, MailerContract and the concrete class. + $container->bind(MailPort::class, static fn(ModuleContainer $c): Mailer => $c->make(Mailer::class)); + $container->bind(MailerContract::class, static fn(ModuleContainer $c): Mailer => $c->make(Mailer::class)); + + // Background delivery job resolves the same Transport. + $container->bindInternal(SendMailJob::class, static fn(ModuleContainer $c): SendMailJob => + new SendMailJob($c->make(Transport::class))); } public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void { + // Job is declared in module.json; nothing to hook here. + } + + /** @param array $config */ + private function makeTransport(array $config): Transport + { + $smtp = $config['smtp'] ?? []; + + return match ((string) ($config['transport'] ?? 'smtp')) { + 'sendmail' => new SendmailTransport((string) ($config['sendmail']['binary'] ?? '/usr/sbin/sendmail')), + 'mail' => new MailTransport(), + 'array' => new ArrayTransport(), + 'log' => new LogTransport(), + default => new SmtpTransport( + hosts: array_values(array_filter(array_map('trim', explode(',', (string) ($smtp['hosts'] ?? 'localhost'))))), + port: (int) ($smtp['port'] ?? 587), + encryption: (string) ($smtp['encryption'] ?? 'tls'), + username: (string) ($smtp['username'] ?? ''), + password: (string) ($smtp['password'] ?? ''), + authMode: (string) ($smtp['auth_mode'] ?? 'auto'), + oauthToken: (string) ($smtp['oauth_token'] ?? ''), + heloDomain: (string) ($smtp['helo_domain'] ?? ''), + timeout: (int) ($smtp['timeout'] ?? 30), + verifyPeer: (bool) ($smtp['verify_peer'] ?? true), + keepAlive: (bool) ($smtp['keep_alive'] ?? false), + allowInsecureAuth: (bool) ($smtp['allow_insecure_auth'] ?? false), + ), + }; + } + + /** @param array $config */ + private function makeDkim(array $config): ?DkimSigner + { + $dkim = $config['dkim'] ?? []; + $domain = (string) ($dkim['domain'] ?? ''); + $selector = (string) ($dkim['selector'] ?? ''); + $key = (string) ($dkim['private_key'] ?? ''); + + if ($domain === '' || $selector === '' || $key === '') { + return null; + } + if (is_file($key) && is_readable($key)) { + $key = (string) file_get_contents($key); + } + + return new DkimSigner($domain, $selector, $key); + } + + /** @return array */ + private function config(): array + { + $default = __DIR__ . '/config/mail.php'; + $path = function_exists('config_path') && is_file(config_path('mail.php')) + ? config_path('mail.php') + : $default; + + /** @var array $config */ + $config = is_file($path) ? require $path : []; + + return is_array($config) ? $config : []; } } diff --git a/plugins/Mail/README.md b/plugins/Mail/README.md new file mode 100644 index 0000000..96e4eee --- /dev/null +++ b/plugins/Mail/README.md @@ -0,0 +1,109 @@ +# Mail Plugin + +> Solves: **`mail.delivery`** · Namespace: **`Plugins\Mail\`** · Type: on-demand GDA module + +A **native, dependency-free** mail stack (no PHPMailer/Symfony Mailer required) +that implements the kernel `MailPort` and adds a rich `MailerContract`. It covers +the feature surface you'd expect from PHPMailer — attachments, inline images, +cc/bcc, DKIM, SMTP with TLS + auth — while staying self-contained. + +## Quick start + +```php +// Rich API (inject MailerContract) +$mailer->dispatch( + $mailer->message() + ->to('customer@example.com', 'Cust') + ->cc('audit@shop.test') + ->bcc('hidden@shop.test') // delivered, never shown in headers + ->replyTo('support@shop.test') + ->subject('Your receipt ☕') // non-ASCII → MIME encoded-word + ->html('

Thanks!

') + ->embed('/path/logo.png', 'logo') // inline image referenced by cid: + ->attach('/path/receipt.pdf') + ->priority(\Plugins\Mail\Domain\Priority::High), +); + +// Kernel MailPort (view-based) — works for any module +$mail->send('customer@example.com', 'Welcome', 'user::emails/verify', ['url' => $url]); +$mail->queue($to, $subject, $view, $data); // background delivery via QueuePort +``` + +## Message API (PHPMailer parity) + +`from` · `sender` · `returnPath` · `to` · `cc` · `bcc` · `replyTo` · `subject` · +`html` · `text` (auto plain-text alternative when only HTML is set) · `charset` · +`priority` · `confirmReadingTo` (read receipt) · `attach` / `attachData` · +`embed` / `embedData` (inline CID) · `header` (custom) · `tag` (metadata). + +## Transports (`MAIL_TRANSPORT`) + +| Value | Notes | +|---|---| +| `smtp` (default) | Native SMTP. `tls` (STARTTLS) or `ssl` (implicit); AUTH `plain`/`login`/`cram-md5`/`xoauth2` (auto-negotiated); **multi-host failover** (comma-separated `MAIL_SMTP_HOSTS`); optional keep-alive. | +| `sendmail` | Pipes to the sendmail binary with `-f` envelope. | +| `mail` | PHP `mail()`. | +| `array` | Captures messages in memory — **tests**. | +| `log` | Writes the full MIME to a log — **dev**. | + +## Security (security-first defaults) + +- **Header-injection proof.** Every address, name, custom header and attachment + filename is rejected if it contains CR/LF/NUL (`Address`, `Message::header`, + `MimeBuilder`, transports) — an attacker cannot smuggle a `Bcc:` through a + user-supplied field. +- **BCC never leaks** — recipients get the mail via the envelope, but `Bcc:` + is never emitted as a header. +- **TLS with peer verification ON by default** (`MAIL_VERIFY_PEER`). +- **Fail-closed STARTTLS** — if the server does not advertise STARTTLS the + connection is refused, never downgraded to plaintext (downgrade protection). +- **No cleartext credential leak** — SMTP AUTH is refused over an unencrypted + channel unless you explicitly set `MAIL_ALLOW_INSECURE_AUTH=true`. +- **DKIM** RSA-SHA256, relaxed/relaxed (`MAIL_DKIM_*`) — publish the public key + at `._domainkey.`. +- **SMTP command injection** blocked (envelope/RCPT re-validated before the wire). + +## Robustness + +- **RFC 5322 header folding** — no header line exceeds 998 chars (folded at + whitespace), so long To/Cc lists and Subjects aren't rejected by strict MTAs. +- **RFC 2047 encoded-words** — non-ASCII Subjects/names are split into multiple + ≤75-char encoded-words (via `mb_encode_mimeheader`), never one oversized blob. +- **Auto plain-text alternative** generated from HTML so every mail is multipart. + +## Performance (built for transactional volume) + +- **Non-blocking by default** — `queue()` hands the built message to the + `QueuePort` (`mail.send` job), so the HTTP request returns immediately; the + worker does the SMTP round-trip. (The User plugin's signup email uses this.) +- **Connection reuse** — set `MAIL_KEEP_ALIVE=true` so a queue worker sends many + messages over ONE SMTP connection (RSET between them) instead of reconnecting. +- **Fast path preserved** — short ASCII headers skip MIME-encoding and folding + entirely; encoding only kicks in when a value actually needs it. + +## Configuration + +`config/mail.php` (all `MAIL_*` env, overridable per project via +`config_path('mail.php')`). Key vars: `MAIL_TRANSPORT`, `MAIL_FROM_ADDRESS/NAME`, +`MAIL_SMTP_HOSTS`/`MAIL_HOST`, `MAIL_PORT`, `MAIL_ENCRYPTION`, +`MAIL_USERNAME`/`MAIL_PASSWORD`, `MAIL_AUTH_MODE`, `MAIL_OAUTH_TOKEN`, +`MAIL_VERIFY_PEER`, `MAIL_KEEP_ALIVE`, `MAIL_DKIM_DOMAIN/SELECTOR/KEY`. + +## Layout + +``` +API/Contracts/MailerContract message() · dispatch(Message) · enqueue(Message) +Application/Mailer MailPort + MailerContract; compile → DKIM → transport/queue +Application/Jobs/SendMailJob background delivery (job name "mail.send") +Domain/ Message (builder), Address (CRLF guard), Attachment, Priority, MailException +Infrastructure/Mime/MimeBuilder multipart mixed/related/alternative + QP/base64 encoders +Infrastructure/Security/DkimSigner RSA-SHA256 relaxed/relaxed +Infrastructure/Transport/ Transport + Smtp/Sendmail/Mail/Array/Log +``` + +## Enabling + +Add `Plugins\Mail\Provider::class` to the project's `withModules([...])`. It binds +`MailPort` + `MailerContract`, so e.g. the User plugin's signup verification email +is delivered automatically. `requires: []` (uses `ViewRendererContract` + +`QueuePort` when present, degrades gracefully otherwise). diff --git a/plugins/Mail/config/mail.php b/plugins/Mail/config/mail.php new file mode 100644 index 0000000..f76c233 --- /dev/null +++ b/plugins/Mail/config/mail.php @@ -0,0 +1,49 @@ + env('MAIL_TRANSPORT', 'smtp'), + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', ''), + 'name' => env('MAIL_FROM_NAME', ''), + ], + + 'charset' => env('MAIL_CHARSET', 'UTF-8'), + 'queue' => env('MAIL_QUEUE', 'mail'), + + 'smtp' => [ + // Comma-separated for failover, e.g. "smtp1.example.com,smtp2.example.com". + 'hosts' => env('MAIL_SMTP_HOSTS', env('MAIL_HOST', 'localhost')), + 'port' => (int) env('MAIL_PORT', 587), + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), // tls | ssl | none + 'username' => env('MAIL_USERNAME', ''), + 'password' => env('MAIL_PASSWORD', ''), + 'auth_mode' => env('MAIL_AUTH_MODE', 'auto'), // auto|plain|login|cram-md5|xoauth2|none + 'oauth_token' => env('MAIL_OAUTH_TOKEN', ''), + 'helo_domain' => env('MAIL_HELO_DOMAIN', ''), + 'timeout' => (int) env('MAIL_TIMEOUT', 30), + 'verify_peer' => filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOL), + 'keep_alive' => filter_var(env('MAIL_KEEP_ALIVE', 'false'), FILTER_VALIDATE_BOOL), + // Security: NEVER auth over plaintext unless explicitly forced. + 'allow_insecure_auth' => filter_var(env('MAIL_ALLOW_INSECURE_AUTH', 'false'), FILTER_VALIDATE_BOOL), + ], + + 'sendmail' => [ + 'binary' => env('MAIL_SENDMAIL_BINARY', '/usr/sbin/sendmail'), + ], + + // DKIM signing — leave domain/selector/key empty to disable. + 'dkim' => [ + 'domain' => env('MAIL_DKIM_DOMAIN', ''), + 'selector' => env('MAIL_DKIM_SELECTOR', ''), + // PEM string OR a path to the private key file. + 'private_key' => env('MAIL_DKIM_KEY', ''), + ], +]; diff --git a/plugins/Mail/module.json b/plugins/Mail/module.json index 4da964a..adfab92 100644 --- a/plugins/Mail/module.json +++ b/plugins/Mail/module.json @@ -1,24 +1,47 @@ { "name": "mail", "version": "1.0.0", - "solves": "mail.smtp", + "solves": "mail.delivery", "type": "module", "requires": [], - "exposes": ["AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\MailPort"], + "exposes": [ + "AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\MailPort", + "Plugins\\Mail\\API\\Contracts\\MailerContract" + ], "routes": [], "emits": [], "listens": [], + "jobs": [ + { "name": "mail.send", "handler": "Plugins\\Mail\\Application\\Jobs\\SendMailJob", "queue": "mail" } + ], + + "documentation": "The Mail plugin — a native, dependency-free MailPort adapter + rich MailerContract. Transports: SMTP (TLS/STARTTLS, AUTH PLAIN/LOGIN/CRAM-MD5/XOAUTH2, multi-host failover, keep-alive), Sendmail, PHP mail(), Array/Log (test/dev). Message API: from/sender/return-path, to/cc/bcc, reply-to, HTML + auto plain-text alternative, file/raw/inline-CID attachments, custom headers, priority, read receipts, charset. Security: CR/LF header-injection guards on every address/header/param, TLS peer verification, DKIM RSA-SHA256 signing (relaxed/relaxed), hidden BCC. send()/queue() (queue via QueuePort + the mail.send job); views render through ViewRendererContract when present, else the view string is treated as raw HTML. Config in config/mail.php (MAIL_* env).", + "config": [ - { "key": "SMTP_HOST", "type": "string", "required": false }, - { "key": "SMTP_PORT", "type": "int", "required": false }, - { "key": "SMTP_USERNAME", "type": "string", "required": false }, - { "key": "SMTP_PASSWORD", "type": "string", "required": false }, - { "key": "SMTP_ENCRYPTION", "type": "string", "required": false }, - { "key": "MAIL_FROM_ADDRESS", "type": "string", "required": false }, - { "key": "MAIL_FROM_NAME", "type": "string", "required": false }, - { "key": "MAIL_VIEWS_PATH", "type": "string", "required": false } + { "key": "MAIL_TRANSPORT", "type": "string", "required": false }, + { "key": "MAIL_FROM_ADDRESS", "type": "string", "required": false }, + { "key": "MAIL_FROM_NAME", "type": "string", "required": false }, + { "key": "MAIL_HOST", "type": "string", "required": false }, + { "key": "MAIL_SMTP_HOSTS", "type": "string", "required": false }, + { "key": "MAIL_PORT", "type": "int", "required": false }, + { "key": "MAIL_ENCRYPTION", "type": "string", "required": false }, + { "key": "MAIL_USERNAME", "type": "string", "required": false }, + { "key": "MAIL_PASSWORD", "type": "string", "required": false }, + { "key": "MAIL_AUTH_MODE", "type": "string", "required": false }, + { "key": "MAIL_OAUTH_TOKEN", "type": "string", "required": false }, + { "key": "MAIL_HELO_DOMAIN", "type": "string", "required": false }, + { "key": "MAIL_TIMEOUT", "type": "int", "required": false }, + { "key": "MAIL_VERIFY_PEER", "type": "bool", "required": false }, + { "key": "MAIL_KEEP_ALIVE", "type": "bool", "required": false }, + { "key": "MAIL_ALLOW_INSECURE_AUTH", "type": "bool", "required": false }, + { "key": "MAIL_SENDMAIL_BINARY","type": "string", "required": false }, + { "key": "MAIL_DKIM_DOMAIN", "type": "string", "required": false }, + { "key": "MAIL_DKIM_SELECTOR", "type": "string", "required": false }, + { "key": "MAIL_DKIM_KEY", "type": "string", "required": false }, + { "key": "MAIL_CHARSET", "type": "string", "required": false }, + { "key": "MAIL_QUEUE", "type": "string", "required": false } ] } diff --git a/plugins/Settings/module.json b/plugins/Settings/module.json index a8d0f6b..52af3d2 100644 --- a/plugins/Settings/module.json +++ b/plugins/Settings/module.json @@ -4,7 +4,7 @@ "solves": "tenant.settings", "type": "module", - "requires": ["database.query"], + "requires": ["database.query","validation.rules"], "exposes": ["Plugins\\Settings\\API\\Contracts\\SettingsServiceContract"], "routes": [ diff --git a/plugins/Tenancy/module.json b/plugins/Tenancy/module.json index cb2cd42..433bbc6 100644 --- a/plugins/Tenancy/module.json +++ b/plugins/Tenancy/module.json @@ -5,7 +5,7 @@ "type": "module", "description": "Multi-tenant control plane: tenant registry + per-tenant database routing. Identifies the tenant by TENANCY_MODE — 'claim' (default: authenticated Identity.tenantId, the SaaS/JWT model) or 'domain' (the Host sub-domain, the anonymous storefront model) — then rebinds an isolated tenant DatabasePort into the request container so every repository talks to the correct tenant database. Database-per-tenant isolation (MySQL/PostgreSQL/SQLite) on top of plugins/Database ConnectionManager.", - "requires": ["tenant.settings","database.management", "auth.identity", "user.management", "view.rendering"], + "requires": ["tenant.settings","database.management", "auth.identity", "user.management", "view.rendering","validation.rules"], "views": "resources/views", diff --git a/plugins/User/API/Contracts/UserServiceContract.php b/plugins/User/API/Contracts/UserServiceContract.php index c4a0983..9131999 100644 --- a/plugins/User/API/Contracts/UserServiceContract.php +++ b/plugins/User/API/Contracts/UserServiceContract.php @@ -20,8 +20,23 @@ interface UserServiceContract /** Keyset-paginated listing (admin-only). */ public function list(ListUsersQuery $query): UserPage; + /** Admin/back-office registration — returns the full record for display. */ public function register(RegisterUserDTO $dto): UserDTO; + /** + * Public self-signup. Returns ONLY the plaintext verification token for the + * caller to email — never the identity record, so a public registrant learns + * nothing about the created account beyond "check your inbox". + */ + public function registerPublic(RegisterUserDTO $dto): string; + + /** + * Confirm an email from the PUBLIC (unauthenticated) verification link. The + * token is matched by its stored hash and must be unexpired + unconsumed. + * Returns false on any miss. No identity required — this is the pre-login flow. + */ + public function verifyEmailByToken(string $token): bool; + public function find(string $id): ?UserDTO; /** Look up a user by username OR email (no credential check). Null if absent. */ diff --git a/plugins/User/API/DTOs/RegisterUserDTO.php b/plugins/User/API/DTOs/RegisterUserDTO.php index 8035792..ee8b057 100644 --- a/plugins/User/API/DTOs/RegisterUserDTO.php +++ b/plugins/User/API/DTOs/RegisterUserDTO.php @@ -9,15 +9,17 @@ use Plugins\User\Domain\ValueObjects\Email; use Plugins\User\Domain\ValueObjects\PasswordPolicy; use Plugins\User\Domain\ValueObjects\Username; +use Plugins\Validation\AbstractDto; /** - * Validated registration input. Field-shape validation happens here; the value - * objects (Username, Email) enforce the deeper domain rules. + * Validated registration input. rules() carry the field SHAPE (mirrored by the + * Username/Email value objects as defense-in-depth); PasswordPolicy carries the + * password STRENGTH rules and is merged into the same 422 response. * * The plaintext password is held only long enough for the service to hash it * via the HashingPort, then discarded — it is never persisted or logged. */ -final readonly class RegisterUserDTO +final readonly class RegisterUserDTO extends AbstractDto { public function __construct( public Username $username, @@ -30,44 +32,75 @@ public function __construct( * event so Tenancy can assign membership. '' when there is no tenant. */ public string $tenantId = '', + /** + * OPTIONAL tenant-scoped profile fields a public signup may submit in the + * same request (first_name/last_name/phone/timezone/locale — primitives + * only). Written asynchronously by a tenant-side listener off the + * user.registered event. Empty = none. + * + * @var array + */ + public array $profile = [], ) {} - public static function fromRequest(Request $request): self - { - $errors = []; - - $usernameRaw = trim((string) $request->input('username', '')); - $emailRaw = trim((string) $request->input('email', '')); - $password = (string) $request->input('password', ''); + /** Profile keys accepted at signup — never trust arbitrary request input. */ + private const PROFILE_FIELDS = [ + 'first_name' => 80, + 'last_name' => 80, + 'phone' => 15, + 'timezone' => 50, + 'locale' => 5, + ]; - $username = null; - try { - $username = Username::fromString($usernameRaw); - } catch (\DomainException $e) { - $errors['username'] = $e->getMessage(); - } - - $email = null; - try { - $email = Email::fromString($emailRaw); - } catch (\DomainException $e) { - $errors['email'] = $e->getMessage(); - } + protected static function rules(): array + { + return [ + 'username' => 'required|string|min:5|max:50|regex:/^[A-Za-z0-9._-]+$/', + 'email' => 'required|string|email|max:150', + 'password' => 'required|string', + ]; + } - // Strength rules are centralised in the PasswordPolicy value object. - $errors += PasswordPolicy::validate($password); + protected static function messages(): array + { + return [ + 'username.min' => 'Username must be between 5 and 50 characters.', + 'username.max' => 'Username must be between 5 and 50 characters.', + 'username.regex' => 'Username may only contain letters, digits, dot, underscore and hyphen.', + 'email.email' => 'Email is not a valid address.', + 'email.max' => 'Email must be 150 characters or fewer.', + ]; + } + public static function fromRequest(Request $request): self + { + // Shape errors + password-strength errors combine into one 422. + $errors = static::collectErrors($request->all()); + $errors += PasswordPolicy::validate((string) $request->input('password', '')); if ($errors !== []) { throw new ValidationException($errors); } - /** @var Username $username */ - /** @var Email $email */ return new self( - username: $username, - email: $email, - password: $password, + username: Username::fromString(trim((string) $request->input('username', ''))), + email: Email::fromString(trim((string) $request->input('email', ''))), + password: (string) $request->input('password', ''), tenantId: (string) ($request->attribute('tenant') ?? ''), + profile: self::profileFrom($request), ); } + + /** @return array only the non-empty, clipped profile fields. */ + private static function profileFrom(Request $request): array + { + $profile = []; + foreach (self::PROFILE_FIELDS as $key => $max) { + $value = trim((string) $request->input($key, '')); + if ($value !== '') { + $profile[$key] = mb_substr($value, 0, $max); + } + } + + return $profile; + } } diff --git a/plugins/User/API/DTOs/UpdateNotificationPreferencesDTO.php b/plugins/User/API/DTOs/UpdateNotificationPreferencesDTO.php index 605a7a2..af77ed2 100644 --- a/plugins/User/API/DTOs/UpdateNotificationPreferencesDTO.php +++ b/plugins/User/API/DTOs/UpdateNotificationPreferencesDTO.php @@ -4,9 +4,9 @@ namespace Plugins\User\API\DTOs; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; use Plugins\User\Domain\Entities\UserNotificationPreferences; +use Plugins\Validation\AbstractDto; /** * Validated notification-preferences input. User id comes from the Identity. @@ -17,15 +17,29 @@ * partial payload never silently disables an omitted channel (security topics * stay on unless explicitly turned off). Unknown flag keys are rejected (422). */ -final readonly class UpdateNotificationPreferencesDTO +final readonly class UpdateNotificationPreferencesDTO extends AbstractDto { /** @param array $flags */ public function __construct( public array $flags, ) {} + protected static function rules(): array + { + // Only the envelope is shape-validated; the per-flag mapping below is + // business logic (present-key extraction), not validation. + return ['flags' => 'nullable|array']; + } + + protected static function messages(): array + { + return ['flags.array' => 'flags must be an object of channel → topic booleans.']; + } + public static function fromRequest(Request $request): self { + static::validated($request); + $provided = []; $nested = $request->input('flags'); @@ -46,10 +60,6 @@ public static function fromRequest(Request $request): self } } - if ($nested !== null && !is_array($nested)) { - throw new ValidationException(['flags' => 'flags must be an object of channel → topic booleans.']); - } - return new self($provided); } diff --git a/plugins/User/API/DTOs/UpdatePreferencesDTO.php b/plugins/User/API/DTOs/UpdatePreferencesDTO.php index b6ba16d..9b939ff 100644 --- a/plugins/User/API/DTOs/UpdatePreferencesDTO.php +++ b/plugins/User/API/DTOs/UpdatePreferencesDTO.php @@ -4,15 +4,15 @@ namespace Plugins\User\API\DTOs; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; use Plugins\User\Domain\ValueObjects\Theme; +use Plugins\Validation\AbstractDto; /** * Validated preferences-update input (idempotent full replace). User id comes * from the Identity, never the body. */ -final readonly class UpdatePreferencesDTO +final readonly class UpdatePreferencesDTO extends AbstractDto { public function __construct( public ?string $language, @@ -24,35 +24,32 @@ public function __construct( public bool $screenReaderHints, ) {} - public static function fromRequest(Request $request): self + protected static function rules(): array { - $errors = []; - - $language = self::trimOrNull($request->input('language')); - $currency = self::trimOrNull($request->input('currency')); - - if ($language !== null && !preg_match('/^[a-zA-Z]{2,10}(-[a-zA-Z]{2,10})?$/', $language)) { - $errors['language'] = 'Language must be a 2–10 letter tag, e.g. en or en-GB.'; - } - if ($currency !== null && !preg_match('/^[a-zA-Z]{3}$/', $currency)) { - $errors['currency'] = 'Currency must be a 3-letter ISO 4217 code, e.g. UGX.'; - } + return [ + 'language' => 'nullable|regex:/^[a-zA-Z]{2,10}(-[a-zA-Z]{2,10})?$/', + 'currency' => 'nullable|regex:/^[a-zA-Z]{3}$/', + 'theme' => 'nullable|enum:' . Theme::class, + ]; + } - $theme = Theme::System; - try { - $theme = Theme::fromString((string) $request->input('theme', 'system')); - } catch (\DomainException $e) { - $errors['theme'] = $e->getMessage(); - } + protected static function messages(): array + { + return [ + 'language.regex' => 'Language must be a 2–10 letter tag, e.g. en or en-GB.', + 'currency.regex' => 'Currency must be a 3-letter ISO 4217 code, e.g. UGX.', + 'theme.enum' => 'Theme must be one of: light, dark, system.', + ]; + } - if ($errors !== []) { - throw new ValidationException($errors); - } + public static function fromRequest(Request $request): self + { + static::validated($request); return new self( - language: $language, - currency: $currency, - theme: $theme, + language: self::trimOrNull($request->input('language')), + currency: self::trimOrNull($request->input('currency')), + theme: Theme::fromString((string) $request->input('theme', 'system')), reduceMotion: $request->boolean('reduceMotion'), largerText: $request->boolean('largerText'), highContrast: $request->boolean('highContrast'), diff --git a/plugins/User/API/DTOs/UpdatePrivacyDTO.php b/plugins/User/API/DTOs/UpdatePrivacyDTO.php index cd963e1..50fedc5 100644 --- a/plugins/User/API/DTOs/UpdatePrivacyDTO.php +++ b/plugins/User/API/DTOs/UpdatePrivacyDTO.php @@ -4,15 +4,15 @@ namespace Plugins\User\API\DTOs; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; use Plugins\User\Domain\ValueObjects\ProfileVisibility; +use Plugins\Validation\AbstractDto; /** * Validated privacy-update input (idempotent full replace). User id comes from * the Identity, never the body. */ -final readonly class UpdatePrivacyDTO +final readonly class UpdatePrivacyDTO extends AbstractDto { public function __construct( public ProfileVisibility $profileVisibility, @@ -22,17 +22,22 @@ public function __construct( public bool $analyticsOptIn, ) {} + protected static function rules(): array + { + return ['profileVisibility' => 'nullable|enum:' . ProfileVisibility::class]; + } + + protected static function messages(): array + { + return ['profileVisibility.enum' => 'Profile visibility must be one of: public, private, contacts.']; + } + public static function fromRequest(Request $request): self { - $visibility = ProfileVisibility::Public; - try { - $visibility = ProfileVisibility::fromString((string) $request->input('profileVisibility', 'public')); - } catch (\DomainException $e) { - throw new ValidationException(['profileVisibility' => $e->getMessage()]); - } + static::validated($request); return new self( - profileVisibility: $visibility, + profileVisibility: ProfileVisibility::fromString((string) $request->input('profileVisibility', 'public')), showPhone: $request->boolean('showPhone'), showEmail: $request->boolean('showEmail'), marketingOptIn: $request->boolean('marketingOptIn'), diff --git a/plugins/User/API/DTOs/UpdateProfileDTO.php b/plugins/User/API/DTOs/UpdateProfileDTO.php index f46e5c1..3b06326 100644 --- a/plugins/User/API/DTOs/UpdateProfileDTO.php +++ b/plugins/User/API/DTOs/UpdateProfileDTO.php @@ -4,17 +4,17 @@ namespace Plugins\User\API\DTOs; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; +use Plugins\Validation\AbstractDto; /** * Validated profile-update input (idempotent full replace). The user id is NEVER * read from the body — it comes from the authenticated Identity in the service. * - * Field-level validation happens HERE; the UserProfile entity re-guards the same - * rules as defense-in-depth. + * Shape validation is declared in rules() (via the shared Validator); the + * UserProfile entity re-guards the same rules as defense-in-depth. */ -final readonly class UpdateProfileDTO +final readonly class UpdateProfileDTO extends AbstractDto { public function __construct( public ?string $firstName, @@ -25,41 +25,38 @@ public function __construct( public ?string $phone, ) {} - public static function fromRequest(Request $request): self + protected static function rules(): array { - $errors = []; - - $firstName = self::trimOrNull($request->input('firstName')); - $lastName = self::trimOrNull($request->input('lastName')); - $avatarUrl = self::trimOrNull($request->input('avatarUrl')); - $timezone = self::trimOrNull($request->input('timezone')); - $locale = self::trimOrNull($request->input('locale')); - $phone = self::trimOrNull($request->input('phone')); + return [ + 'firstName' => 'nullable|string|max:80', + 'lastName' => 'nullable|string|max:80', + 'avatarUrl' => 'nullable|http_url|max:500', + 'timezone' => 'nullable|timezone', + 'locale' => 'nullable|regex:/^[a-z]{2}_[A-Z]{2}$/', + 'phone' => 'nullable|regex:/^\+?[0-9]{7,15}$/', + ]; + } - if ($firstName !== null && mb_strlen($firstName) > 80) { - $errors['firstName'] = 'First name cannot exceed 80 characters.'; - } - if ($lastName !== null && mb_strlen($lastName) > 80) { - $errors['lastName'] = 'Last name cannot exceed 80 characters.'; - } - if ($avatarUrl !== null && !self::isHttpUrl($avatarUrl)) { - $errors['avatarUrl'] = 'Avatar URL must be a valid http(s) URL up to 500 characters.'; - } - if ($timezone !== null && !in_array($timezone, timezone_identifiers_list(), true)) { - $errors['timezone'] = 'Unknown timezone.'; - } - if ($locale !== null && !preg_match('/^[a-z]{2}_[A-Z]{2}$/', $locale)) { - $errors['locale'] = 'Locale must be in ll_CC form, e.g. en_US.'; - } - if ($phone !== null && !preg_match('/^\+?[0-9]{7,15}$/', $phone)) { - $errors['phone'] = 'Phone must be 7–15 digits (optional leading +).'; - } + protected static function messages(): array + { + return [ + 'locale.regex' => 'Locale must be in ll_CC form, e.g. en_US.', + 'phone.regex' => 'Phone must be 7–15 digits (optional leading +).', + ]; + } - if ($errors !== []) { - throw new ValidationException($errors); - } + public static function fromRequest(Request $request): self + { + static::validated($request); // throws 422 on bad shape - return new self($firstName, $lastName, $avatarUrl, $timezone, $locale, $phone); + return new self( + firstName: self::trimOrNull($request->input('firstName')), + lastName: self::trimOrNull($request->input('lastName')), + avatarUrl: self::trimOrNull($request->input('avatarUrl')), + timezone: self::trimOrNull($request->input('timezone')), + locale: self::trimOrNull($request->input('locale')), + phone: self::trimOrNull($request->input('phone')), + ); } private static function trimOrNull(mixed $value): ?string @@ -70,14 +67,4 @@ private static function trimOrNull(mixed $value): ?string $value = trim((string) $value); return $value === '' ? null : $value; } - - /** http(s) only — avatar URLs are rendered as , so reject other schemes. */ - private static function isHttpUrl(string $url): bool - { - if (mb_strlen($url) > 500 || !filter_var($url, FILTER_VALIDATE_URL)) { - return false; - } - $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); - return $scheme === 'http' || $scheme === 'https'; - } } diff --git a/plugins/User/API/DTOs/UpdateUserDTO.php b/plugins/User/API/DTOs/UpdateUserDTO.php index d5a1da0..c9ad588 100644 --- a/plugins/User/API/DTOs/UpdateUserDTO.php +++ b/plugins/User/API/DTOs/UpdateUserDTO.php @@ -9,16 +9,18 @@ use Plugins\User\Domain\ValueObjects\Email; use Plugins\User\Domain\ValueObjects\PasswordPolicy; use Plugins\User\Domain\ValueObjects\Username; +use Plugins\Validation\AbstractDto; /** - * Partial-update input. Every field is optional: only the keys PRESENT in the - * request are applied (PATCH semantics), so a caller can change just the email - * without resending the username or password. + * Partial-update input. Every field is optional: only the keys PRESENT and + * non-empty are applied (PATCH semantics), so a caller can change just the email + * without resending the username or password. The rules carry no `required`, so + * the engine skips any absent/empty field. * * The plaintext password (when present) is held only long enough for the * service to hash it via the HashingPort, then discarded. */ -final readonly class UpdateUserDTO +final readonly class UpdateUserDTO extends AbstractDto { public function __construct( public ?Username $username, @@ -26,40 +28,46 @@ public function __construct( public ?string $password, ) {} - public static function fromRequest(Request $request): self + protected static function rules(): array { - $errors = []; + return [ + 'username' => 'string|min:5|max:50|regex:/^[A-Za-z0-9._-]+$/', + 'email' => 'string|email|max:150', + 'password' => 'string', + ]; + } - $username = null; - if ($request->has('username')) { - try { - $username = Username::fromString(trim((string) $request->input('username', ''))); - } catch (\DomainException $e) { - $errors['username'] = $e->getMessage(); - } - } + protected static function messages(): array + { + return [ + 'username.min' => 'Username must be between 5 and 50 characters.', + 'username.max' => 'Username must be between 5 and 50 characters.', + 'username.regex' => 'Username may only contain letters, digits, dot, underscore and hyphen.', + 'email.email' => 'Email is not a valid address.', + 'email.max' => 'Email must be 150 characters or fewer.', + ]; + } - $email = null; - if ($request->has('email')) { - try { - $email = Email::fromString(trim((string) $request->input('email', ''))); - } catch (\DomainException $e) { - $errors['email'] = $e->getMessage(); - } + public static function fromRequest(Request $request): self + { + $errors = static::collectErrors($request->all()); + if ($request->filled('password')) { + $errors += PasswordPolicy::validate((string) $request->input('password', '')); } - - $password = null; - if ($request->has('password')) { - $password = (string) $request->input('password', ''); - $errors += PasswordPolicy::validate($password); + if ($errors !== []) { + throw new ValidationException($errors); } - if ($username === null && $email === null && $password === null && $errors === []) { - $errors['_'] = 'Provide at least one of: username, email, password.'; - } + $username = $request->filled('username') + ? Username::fromString(trim((string) $request->input('username', ''))) + : null; + $email = $request->filled('email') + ? Email::fromString(trim((string) $request->input('email', ''))) + : null; + $password = $request->filled('password') ? (string) $request->input('password', '') : null; - if ($errors !== []) { - throw new ValidationException($errors); + if ($username === null && $email === null && $password === null) { + throw new ValidationException(['_' => 'Provide at least one of: username, email, password.']); } return new self(username: $username, email: $email, password: $password); diff --git a/plugins/User/API/DTOs/VerifyEmailDTO.php b/plugins/User/API/DTOs/VerifyEmailDTO.php index ad616e8..d4579ce 100644 --- a/plugins/User/API/DTOs/VerifyEmailDTO.php +++ b/plugins/User/API/DTOs/VerifyEmailDTO.php @@ -4,26 +4,33 @@ namespace Plugins\User\API\DTOs; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; +use Plugins\Validation\AbstractDto; /** * Email-verification input: a signed/opaque token the user received by email. * The token is verified out-of-band by the service (timing-safe compare). */ -final readonly class VerifyEmailDTO +final readonly class VerifyEmailDTO extends AbstractDto { public function __construct( public string $token, ) {} + protected static function rules(): array + { + return ['token' => 'required|string']; + } + + protected static function messages(): array + { + return ['token.required' => 'A verification token is required.']; + } + public static function fromRequest(Request $request): self { - $token = trim((string) $request->input('token', '')); - if ($token === '') { - throw new ValidationException(['token' => 'A verification token is required.']); - } + static::validated($request); - return new self(token: $token); + return new self(token: trim((string) $request->input('token', ''))); } } diff --git a/plugins/User/API/IntegrationEvents/UserRegisteredIntegrationEvent.php b/plugins/User/API/IntegrationEvents/UserRegisteredIntegrationEvent.php index b117853..46d9a52 100644 --- a/plugins/User/API/IntegrationEvents/UserRegisteredIntegrationEvent.php +++ b/plugins/User/API/IntegrationEvents/UserRegisteredIntegrationEvent.php @@ -22,8 +22,16 @@ public function __construct( public string $occurredAt, /** Originating tenant ('' when none) — lets a subscriber assign membership. */ public string $tenantId = '', + /** + * Optional profile fields submitted at signup (primitives only, e.g. + * first_name/last_name/phone/timezone/locale). A tenant-side listener + * writes these to user_profiles. Empty when none were submitted. + * + * @var array + */ + public array $profile = [], ) { - $this->version = '1.0'; + $this->version = '1.1'; } public function name(): string @@ -36,7 +44,7 @@ public function version(): string return $this->version; } - /** @return array */ + /** @return array */ public function payload(): array { return [ @@ -45,6 +53,7 @@ public function payload(): array 'email' => $this->email, 'occurredAt' => $this->occurredAt, 'tenantId' => $this->tenantId, + 'profile' => $this->profile, 'version' => $this->version, ]; } diff --git a/plugins/User/Application/Ports/UserStore.php b/plugins/User/Application/Ports/UserStore.php index ad9fc94..6c246bb 100644 --- a/plugins/User/Application/Ports/UserStore.php +++ b/plugins/User/Application/Ports/UserStore.php @@ -28,6 +28,9 @@ public function findByIdentifier(string $identifier): ?User; /** Look up an active user by the SHA-256 hash of a "remember me" token. */ public function findByRememberToken(string $tokenHash): ?User; + /** Look up an active user by the SHA-256 hash of a pending verification token. */ + public function findByVerificationTokenHash(string $tokenHash): ?User; + /** Persist (or clear, with null) the remember-token hash for a user. */ public function updateRememberToken(string $userId, ?string $tokenHash): void; diff --git a/plugins/User/Application/Services/TenantProfileProvisioner.php b/plugins/User/Application/Services/TenantProfileProvisioner.php new file mode 100644 index 0000000..f308ee1 --- /dev/null +++ b/plugins/User/Application/Services/TenantProfileProvisioner.php @@ -0,0 +1,50 @@ + $profile whitelisted primitive fields + * (first_name, last_name, phone, timezone, locale) from the event. + */ + public function provision(string $userId, array $profile): void + { + if ($userId === '' || $profile === []) { + return; + } + + // Named constructor validates lengths/locale/timezone; omitted fields + // fall back to the table's defaults inside the entity. + $entity = UserProfile::fromInput( + userId: $userId, + firstName: $profile['first_name'] ?? null, + lastName: $profile['last_name'] ?? null, + avatarUrl: null, + timezone: $profile['timezone'] ?? null, + locale: $profile['locale'] ?? null, + phone: $profile['phone'] ?? null, + ); + + $this->profiles->saveProfile($entity); + } +} diff --git a/plugins/User/Application/Services/UserService.php b/plugins/User/Application/Services/UserService.php index ff8d7a5..0178ee7 100644 --- a/plugins/User/Application/Services/UserService.php +++ b/plugins/User/Application/Services/UserService.php @@ -32,7 +32,6 @@ use Plugins\User\Domain\Events\UserRegisteredDomainEvent; use Plugins\User\Domain\Events\UserUpdatedDomainEvent; use Plugins\User\Infrastructure\Audit\AuditLogger; -use Symfony\Component\VarDumper\VarDumper; /** * UserService — orchestrates the user.management domain. @@ -84,7 +83,88 @@ public function list(ListUsersQuery $query): UserPage ); } + /** Verification token lifetime (seconds) — 24h. */ + private const VERIFICATION_TTL = 86400; + + /** + * ADMIN / back-office registration. Returns the FULL user record so it can + * be shown in an admin table. Still arms an email-verification token; when + * you need the plaintext token to email, use registerPublic() instead. + */ public function register(RegisterUserDTO $dto): UserDTO + { + [$user] = $this->provision($dto); + return $user; + } + + /** + * PUBLIC self-signup. Returns ONLY the plaintext verification token for the + * caller to email — never the identity record. A public registrant must not + * receive their id/email/verification state back, so the controller responds + * with a fixed "pending" status and this token stays server-side. + */ + public function registerPublic(RegisterUserDTO $dto): string + { + [, $token] = $this->provision($dto); + return $token; + } + + /** + * Confirm an email from the PUBLIC (unauthenticated) verification link. The + * emailed token is matched by its stored SHA-256 hash and must not be + * expired. One-time: verifyEmail() clears the token on success. Returns + * false on any miss (unknown/expired/consumed) so a forged token reveals + * nothing. + */ + public function verifyEmailByToken(string $token): bool + { + if ($token === '') { + return false; + } + + $user = $this->repository->findByVerificationTokenHash(hash('sha256', $token)); + if ($user === null) { + $this->audit->record('user.email_verify.token_miss', []); + return false; + } + + $expiresAt = $user->emailVerificationExpiresAt(); + if ($expiresAt === null || $expiresAt < new \DateTimeImmutable()) { + $this->audit->record('user.email_verify.token_expired', ['userId' => $user->id()]); + return false; + } + + $this->collector->beginCollection(); + $this->transaction->begin(); + try { + $user->verifyEmail(); + if (!$user->commitChanges()) { + $this->transaction->rollback(); + $this->collector->discard(); + return true; // already verified — idempotent success + } + + $this->flushEvents($user); + $this->repository->update($user); + $this->transaction->commit(); + } catch (\Throwable $e) { + $this->transaction->rollback(); + $this->collector->discard(); + throw $this->wrap($e, 'user.verify_email.failed', ['id' => $user->id()]); + } + + $this->collector->release(); + $this->audit->record('user.email_verified', ['userId' => $user->id()]); + + return true; + } + + /** + * Shared registration core — arms a verification token and persists identity. + * + * @return array{0: UserDTO, 1: string} [record, plaintext verification token] + */ + private function provision(RegisterUserDTO $dto): array { // Cheap pre-check for a friendly 422 before we hit the unique index; // the index + DuplicateUserException is the authoritative guard. @@ -94,6 +174,10 @@ public function register(RegisterUserDTO $dto): UserDTO $this->assertNotBreached($dto->password); + // Emailed once; only its hash is stored. Time-boxed + one-time. + $plainToken = bin2hex(random_bytes(32)); + $expiresAt = (new \DateTimeImmutable())->modify('+' . self::VERIFICATION_TTL . ' seconds'); + $this->collector->beginCollection(); $this->transaction->begin(); try { @@ -102,9 +186,11 @@ public function register(RegisterUserDTO $dto): UserDTO email: $dto->email, passwordHash: $this->hasher->make($dto->password), ); - + $user->startEmailVerification(hash('sha256', $plainToken), $expiresAt); - $this->flushEvents($user, $dto->tenantId); + // Profile (if submitted) rides on the event for a tenant-side write; + // it CANNOT join this central identity transaction (different DB). + $this->flushEvents($user, $dto->tenantId, $dto->profile); $this->repository->insert($user); $this->transaction->commit(); } catch (\Throwable $e) { @@ -116,7 +202,7 @@ public function register(RegisterUserDTO $dto): UserDTO $this->collector->release(); $this->audit->record('user.registered', ['userId' => $user->id()]); - return UserDTO::fromEntity($user); + return [UserDTO::fromEntity($user), $plainToken]; } public function find(string $id): ?UserDTO @@ -360,19 +446,19 @@ public function delete(string $id): bool * Collect the entity's domain events and write their integration * counterparts to the outbox — all inside the active transaction. */ - private function flushEvents(User $user, string $originTenant = ''): void + private function flushEvents(User $user, string $originTenant = '', array $profile = []): void { foreach ($user->releaseEvents() as $event) { $this->collector->collect($event); - $integration = $this->toIntegration($event, $originTenant); + $integration = $this->toIntegration($event, $originTenant, $profile); if ($integration !== null) { $this->outbox->write($integration); } } } - private function toIntegration(DomainEventContract $event, string $originTenant = ''): ?IntegrationEventContract + private function toIntegration(DomainEventContract $event, string $originTenant = '', array $profile = []): ?IntegrationEventContract { return match (true) { $event instanceof UserRegisteredDomainEvent => new UserRegisteredIntegrationEvent( @@ -381,6 +467,7 @@ private function toIntegration(DomainEventContract $event, string $originTenant email: $event->email->value(), occurredAt: $event->occurredAt->format(\DateTimeInterface::RFC3339), tenantId: $originTenant, + profile: $profile, ), $event instanceof UserUpdatedDomainEvent => new UserUpdatedIntegrationEvent( userId: $event->userId->value(), diff --git a/plugins/User/Domain/Entities/User.php b/plugins/User/Domain/Entities/User.php index d7dcda0..83cac1e 100644 --- a/plugins/User/Domain/Entities/User.php +++ b/plugins/User/Domain/Entities/User.php @@ -33,11 +33,12 @@ final class User extends Entity // cast is correct here — a '?datetime' would leak a 'nullable' param into // DatetimeCast and be misread as a literal date format. 'email_verified_at' => 'datetime', + 'email_verification_expires_at' => 'datetime', 'created_at' => 'datetime', ]; - /** Credentials never cross the serialization boundary. */ - protected array $hidden = ['password_hash', 'remember_token']; + /** Credentials + the verification token hash never cross the serialization boundary. */ + protected array $hidden = ['password_hash', 'remember_token', 'email_verification_token_hash']; @@ -63,6 +64,8 @@ public static function register( 'remember_token' => null, 'version' => 1, 'email_verified_at' => null, + 'email_verification_token_hash' => null, + 'email_verification_expires_at' => null, 'created_at' => $createdAt, ]); $user->syncOriginal(); @@ -115,6 +118,31 @@ public function verifyEmail(): void return; } $this->email_verified_at = new \DateTimeImmutable(); + // A consumed/confirmed account holds no live token. + $this->email_verification_token_hash = null; + $this->email_verification_expires_at = null; + } + + /** + * Arm a pending email-verification token. Stores only the SHA-256 HASH of + * the emailed token (the raw token lives only in the email) plus a hard + * expiry. Re-arming replaces any previous pending token. + */ + public function startEmailVerification(string $tokenHash, \DateTimeImmutable $expiresAt): void + { + $this->email_verification_token_hash = $tokenHash; + $this->email_verification_expires_at = $expiresAt; + } + + public function emailVerificationTokenHash(): ?string + { + $v = $this->getRawAttribute('email_verification_token_hash'); + return $v === null ? null : (string) $v; + } + + public function emailVerificationExpiresAt(): ?\DateTimeImmutable + { + return $this->getDate('email_verification_expires_at'); } /** diff --git a/plugins/User/Infrastructure/Http/Controllers/UserController.php b/plugins/User/Infrastructure/Http/Controllers/UserController.php index a7b33e5..c571e1b 100644 --- a/plugins/User/Infrastructure/Http/Controllers/UserController.php +++ b/plugins/User/Infrastructure/Http/Controllers/UserController.php @@ -5,6 +5,7 @@ namespace Plugins\User\Infrastructure\Http\Controllers; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort; use Plugins\User\API\Contracts\UserServiceContract; use Plugins\User\API\DTOs\ListUsersQuery; use Plugins\User\API\DTOs\RegisterUserDTO; @@ -22,6 +23,8 @@ final class UserController extends ApiController { public function __construct( private readonly UserServiceContract $users, + /** Optional — when a MailPort is bound, signup queues a verification email. */ + private readonly ?MailPort $mailer = null, ) {} public function index(): Response @@ -35,10 +38,68 @@ public function index(): Response ]); } + /** + * PUBLIC self-signup. Returns 202 with a non-revealing body — no id, email + * or verification state ever goes back to an unauthenticated registrant. + * The verification token is emailed out-of-band (see the mailer seam below), + * NOT echoed in the response. Location points at the "check your email" flow. + */ public function register(): Response { - $user = $this->users->register(RegisterUserDTO::fromRequest($this->resolveRequest())); - return $this->created($user->toArray(), location: "/ajx/users/{$user->id}"); + $dto = RegisterUserDTO::fromRequest($this->resolveRequest()); + $token = $this->users->registerPublic($dto); + $this->queueVerificationEmail($dto->email->value(), $token); + + return Response::json(['status' => 'pending_verification'], 202) + ->withHeader('Location', '/account/verify'); + } + + /** + * Queue the verification email. The token stays server-side (never in the + * HTTP response); the emailed link points at the project's /verify-email + * page, which POSTs the token to /ajx/users/verify. No-ops when no MailPort + * is bound; a mail failure NEVER breaks signup (the user can request a resend). + */ + private function queueVerificationEmail(string $email, string $token): void + { + if ($this->mailer === null) { + return; + } + try { + $url = $this->resolveRequest()->site()->to('verify-email', ['token' => $token]); + $this->mailer->queue($email, 'Verify your email address', 'user::emails/verify', ['url' => $url]); + } catch (\Throwable) { + // Best-effort — signup already succeeded; swallow mail-transport faults. + } + } + + /** + * ADMIN create — authenticated + permission-gated at the route (auth filter) + * and in the service. Returns the FULL created record so it can be dropped + * straight into the admin table; location points at the admin verify view. + */ + public function adminCreate(): Response + { + $result = $this->users->register(RegisterUserDTO::fromRequest($this->resolveRequest())); + + return $this->created( + $result->toArray(), + location: "/admin/users/{$result->id}/verify", + ); + } + + /** + * PUBLIC email confirmation — the unauthenticated link a registrant clicks. + * Token in the request body/query; no identity required. Always a generic + * response so a bad/expired token reveals nothing. + */ + public function verifyEmailByToken(): Response + { + $token = (string) $this->resolveRequest()->input('token', ''); + + return $this->users->verifyEmailByToken($token) + ? Response::json(['status' => 'verified']) + : $this->unprocessable(['token' => 'This verification link is invalid or has expired.']); } public function show(string $id): Response diff --git a/plugins/User/Infrastructure/Http/Controllers/UserPageController.php b/plugins/User/Infrastructure/Http/Controllers/UserPageController.php index 233ca2c..fb43d98 100644 --- a/plugins/User/Infrastructure/Http/Controllers/UserPageController.php +++ b/plugins/User/Infrastructure/Http/Controllers/UserPageController.php @@ -50,12 +50,6 @@ public function settings(): Response return $this->page('user::account/settings', ['title' => 'Account settings'], '/ajx'); } - /** Feedback demo — create / list / view / update-status CRUD. */ - public function feedback(): Response - { - return $this->page('user::account/feedback', ['title' => 'Feedback'], '/ajx'); - } - /** @param array $data */ private function page(string $view, array $data, string $apiBase = self::API_BASE): Response { diff --git a/plugins/User/Infrastructure/Listeners/ProvisionTenantProfileListener.php b/plugins/User/Infrastructure/Listeners/ProvisionTenantProfileListener.php new file mode 100644 index 0000000..9cf1ee4 --- /dev/null +++ b/plugins/User/Infrastructure/Listeners/ProvisionTenantProfileListener.php @@ -0,0 +1,67 @@ +name() !== 'user.registered') { + return; + } + + $payload = $event->payload(); + $tenantId = (string) ($payload['tenantId'] ?? ''); + $userId = (string) ($payload['userId'] ?? ''); + $profile = $payload['profile'] ?? []; + + // No tenant, no profile, or no resolver bound → nothing to persist. + if ($this->connections === null || $tenantId === '' || $userId === '' || !is_array($profile) || $profile === []) { + return; + } + + // Compose service → repository against the ORIGIN tenant's connection. + // The listener never calls the DatabasePort itself — the repository does. + $repository = new UserSettingsRepository($this->connections->for($tenantId)); + $provisioner = new TenantProfileProvisioner($repository); + + $provisioner->provision($userId, array_intersect_key($profile, array_flip(self::ALLOWED))); + } +} diff --git a/plugins/User/Infrastructure/Persistence/UserRepository.php b/plugins/User/Infrastructure/Persistence/UserRepository.php index 9284fbb..94b3970 100644 --- a/plugins/User/Infrastructure/Persistence/UserRepository.php +++ b/plugins/User/Infrastructure/Persistence/UserRepository.php @@ -35,7 +35,8 @@ final class UserRepository implements UserStore private const COLUMNS = 'user_id, username, email, password_hash, remember_token, - version, email_verified_at, created_at'; + version, email_verified_at, email_verification_token_hash, + email_verification_expires_at, created_at'; public function __construct( private readonly DatabasePort $db, @@ -97,6 +98,22 @@ public function findByRememberToken(string $tokenHash): ?User return $row === null ? null : self::hydrate($row); } + /** + * Resolve a user by the SHA-256 hash of a pending email-verification token. + * Expiry is checked in the service (it holds the clock); an empty hash never + * matches so a blank/NULL column cannot confirm a forged empty token. + */ + public function findByVerificationTokenHash(string $tokenHash): ?User + { + if ($tokenHash === '') { + return null; + } + + $row = $this->fetchBy('email_verification_token_hash', $tokenHash); + + return $row === null ? null : self::hydrate($row); + } + /** Persist (or clear, with null) the remember-token hash for a user. */ public function updateRememberToken(string $userId, ?string $tokenHash): void { @@ -159,10 +176,12 @@ public function insert(User $user): void $this->db->execute( 'INSERT INTO ' . self::TABLE . ' (user_id, username, email, password_hash, remember_token, - version, email_verified_at, created_at, updated_at) + version, email_verified_at, email_verification_token_hash, + email_verification_expires_at, created_at, updated_at) VALUES (:user_id, :username, :email, :password_hash, :remember_token, - :version, :email_verified_at, :created_at, :updated_at)', + :version, :email_verified_at, :verif_token, :verif_expires, + :created_at, :updated_at)', [ 'user_id' => $user->id(), 'username' => $user->username(), @@ -171,6 +190,8 @@ public function insert(User $user): void 'remember_token' => $user->rememberToken(), 'version' => $user->version(), 'email_verified_at' => self::fmt($user->emailVerifiedAt()), + 'verif_token' => $user->emailVerificationTokenHash(), + 'verif_expires' => self::fmt($user->emailVerificationExpiresAt()), 'created_at' => $now, 'updated_at' => $now, ], @@ -205,6 +226,8 @@ public function update(User $user): void password_hash = :password_hash, remember_token = :remember_token, email_verified_at = :email_verified_at, + email_verification_token_hash = :verif_token, + email_verification_expires_at = :verif_expires, version = :version, updated_at = :updated_at WHERE user_id = :user_id @@ -215,6 +238,8 @@ public function update(User $user): void 'password_hash' => $user->passwordHash(), 'remember_token' => $user->rememberToken(), 'email_verified_at' => self::fmt($user->emailVerifiedAt()), + 'verif_token' => $user->emailVerificationTokenHash(), + 'verif_expires' => self::fmt($user->emailVerificationExpiresAt()), 'version' => $user->version(), 'updated_at' => self::now(), 'user_id' => $user->id(), diff --git a/plugins/User/Provider.php b/plugins/User/Provider.php index 87b033b..84e1548 100644 --- a/plugins/User/Provider.php +++ b/plugins/User/Provider.php @@ -15,22 +15,22 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HashingPort; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HttpClientPort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort; use Plugins\User\Application\Ports\BreachChecker; use Plugins\User\Infrastructure\Gateways\NullBreachChecker; use Plugins\User\Infrastructure\Gateways\PwnedPasswordGateway; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\User\API\Contracts\UserServiceContract; -use Plugins\User\Application\Services\FeedbackService; use Plugins\User\Application\Services\UserService; use Plugins\User\Application\Services\UserSettingsService; use Plugins\User\Infrastructure\Audit\AuditLogger; use Plugins\User\Infrastructure\Cli\RelayUserOutboxCommand; -use Plugins\User\Infrastructure\Http\Controllers\FeedbackController; +use Plugins\User\Infrastructure\Http\Controllers\UserController; use Plugins\User\Infrastructure\Http\Controllers\UserPageController; use Plugins\User\Infrastructure\Http\Controllers\UserSettingsController; +use Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener; use Plugins\User\Infrastructure\Outbox\OutboxWriter; -use Plugins\User\Infrastructure\Persistence\FeedbackRepository; use Plugins\User\Infrastructure\Persistence\UserRepository; use Plugins\User\Infrastructure\Persistence\UserSettingsRepository; use Plugins\View\API\Contracts\ViewRendererContract; @@ -71,9 +71,8 @@ public function requires(): array public function exposes(): array { // Only UserServiceContract is consumed cross-module (Auth/Tenancy). - // Feedback + settings are internal to this plugin (their own - // controllers), so they are NOT published — the controllers depend on - // the concrete services directly. + // Settings are internal to this plugin (their own controller), so they + // are NOT published — the controller depends on the concrete service. return [ UserServiceContract::class, ]; @@ -133,30 +132,20 @@ public function register(ModuleContainer $container): void breachChecker: $c->make(BreachChecker::class), )); + // Public/admin JSON controller. Bound explicitly so the OPTIONAL MailPort + // is injected only when a project wired one (else null → email is skipped). + $container->bindInternal(UserController::class, static fn(ModuleContainer $c) => + new UserController( + $c->make(UserServiceContract::class), + $c->has(MailPort::class) ? $c->make(MailPort::class) : null, + )); + // HTML page controller (renders the AJAX-driven UI shell). $container->bindInternal(UserPageController::class, static fn(ModuleContainer $c) => new UserPageController( $c->make(ViewRendererContract::class), )); - // ── Feedback (TENANT-scoped, internal) ─────────────────────────────── - // Feedback lives in the request's TENANT database, so the repository - // takes the tenant-routed DatabasePort directly (NOT self::central()). - // The service is internal — its own controller depends on it directly. - $container->bindInternal(FeedbackRepository::class, static fn(ModuleContainer $c) => - new FeedbackRepository($c->make(DatabasePort::class))); - - $container->bindInternal(FeedbackService::class, static fn(ModuleContainer $c) => - new FeedbackService( - repository: $c->make(FeedbackRepository::class), - eventBus: $c->make(EventBus::class), - identity: $c->make(Identity::class), - audit: $c->make(AuditLogger::class), - )); - - $container->bindInternal(FeedbackController::class, static fn(ModuleContainer $c) => - new FeedbackController($c->make(FeedbackService::class))); - // ── Per-user settings (TENANT-scoped singletons, internal) ─────────── // One service + one repository for all four settings resources. Scoped // to the authenticated Identity (self only); tenant-routed DatabasePort. @@ -178,6 +167,11 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke { // Outbox relay — resolved via CoreContainer autowiring (DatabasePort + EventBus). $cli->command(RelayUserOutboxCommand::class); + + // Write the per-tenant user_profiles row from an at-signup profile block. + // Resolved from the CoreContainer: the PROJECT binds this WITH a + // TenantConnectionResolverContract to make it write (else it no-ops). + $events->subscribe('user.registered', ProvisionTenantProfileListener::class); } /** diff --git a/plugins/User/README.md b/plugins/User/README.md index 1690ac5..79348dd 100644 --- a/plugins/User/README.md +++ b/plugins/User/README.md @@ -39,16 +39,22 @@ adapters behind ports, and a published API contract that other modules consume. | Capability | Entry point | Notes | |---|---|---| -| Register a user | `POST /ajx/users` | Public, rate-limited; emits `user.registered` | +| Register (public) | `POST /ajx/users` | Public self-signup, rate-limited. Returns `202 {status:"pending_verification"}` — **no identity data**. Queues a verification email (optional `MailPort`). May submit a profile block → tenant `user_profiles`. Emits `user.registered` | +| Register (admin) | `POST /ajx/admin/users` | `auth` + `user:create`. Returns the FULL created record for the admin table | +| Verify email (public) | `POST /ajx/users/verify` | **Unauthenticated**, token-based: SHA-256-stored, one-time, 24h expiry. Sets `email_verified_at` | +| Verify email (self/admin) | `POST /ajx/users/{id}/verify-email` | Authenticated variant (self or `user:update-any`) | | List users | `GET /ajx/users` | Admin-only; keyset paginated | | Show a user | `GET /ajx/users/{id}` | Self or `user:read-any` | | Update (partial) | `PUT/PATCH /ajx/users/{id}` | Self or `user:update-any`; optimistic-locked; emits `user.updated` | -| Verify email | `POST /ajx/users/{id}/verify-email` | Sets `email_verified_at` (the login gate); emits `user.updated` | | Soft-delete | `DELETE /ajx/users/{id}` | Self or `user:delete-any`; emits `user.deleted` | | Verify credentials | `UserServiceContract::verifyCredentials()` | Timing-safe, lockout, rehash-on-login; requires a verified email | -| **Feedback** | `POST/GET/PATCH /ajx/feedback[...]` | TENANT-scoped; submit (any user) + admin triage. See [Tenant-scoped sub-resources](#tenant-scoped-sub-resources-feedback--settings) | | **Settings** | `GET/PUT /ajx/{profile,preferences,privacy,notification-preferences}` | TENANT-scoped, self-only; one consolidated service | -| HTML UI | `GET /users[...]`, `/account/settings`, `/account/feedback` | AJAX-driven, cookie auth, CSRF on every form | +| HTML UI | `GET /users[...]`, `/account/settings` | AJAX-driven, cookie auth, CSRF on every form | + +> **Recent changes** +> - **Feedback moved out** into its own [`Plugins\Feedback`](../Feedback/README.md) plugin (one plugin, one domain). The `/ajx/feedback` routes + `user_feedback` table now live there. +> - **Registration split** into public (`registerPublic` → token only) vs admin (`register` → full record); public **email verification is token-based** (hashed, one-time, 24h) via `verifyEmailByToken`. +> - **Input DTOs** now extend `Plugins\Validation\AbstractDto` and declare `rules()` instead of hand-rolled validation. --- diff --git a/plugins/User/database/migrations/2026_01_01_000002_add_email_verification_token_to_users.php b/plugins/User/database/migrations/2026_01_01_000002_add_email_verification_token_to_users.php new file mode 100644 index 0000000..c94e711 --- /dev/null +++ b/plugins/User/database/migrations/2026_01_01_000002_add_email_verification_token_to_users.php @@ -0,0 +1,46 @@ +table('users', static function ($t) { + // SHA-256 (64 hex chars) of the emailed verification token. NULL once + // the email is verified or before any token is issued. + $t->char('email_verification_token_hash', 64)->nullable() + ->comment('SHA-256 of the emailed verification token — never the raw token'); + + // Hard expiry for the pending token; a link past this is rejected. + $t->timestamp('email_verification_expires_at')->nullable() + ->comment('When the pending verification token stops being valid'); + + // Single-row lookup by token hash on the public verify endpoint. + $t->index(['email_verification_token_hash'], 'idx_email_verif_token'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->table('users', static function ($t) { + $t->dropIndex('idx_email_verif_token'); + $t->dropColumn('email_verification_token_hash'); + $t->dropColumn('email_verification_expires_at'); + }); + } +}; diff --git a/plugins/User/module.json b/plugins/User/module.json index 555c749..0ca546f 100644 --- a/plugins/User/module.json +++ b/plugins/User/module.json @@ -4,7 +4,7 @@ "solves": "user.management", "type": "module", - "requires": ["database.management", "crypto.services", "cache.redis", "view.rendering", "http.client"], + "requires": ["database.management", "crypto.services", "cache.redis", "view.rendering", "http.client","validation.rules","mail.delivery","feedback.management"], "exposes": ["Plugins\\User\\API\\Contracts\\UserServiceContract"], "views": "resources/views", @@ -20,21 +20,17 @@ { "method": "GET", "path": "/users/{id}/edit", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@edit" }, { "method": "GET", "path": "/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@show" }, { "method": "GET", "path": "/account/settings", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@settings" }, - { "method": "GET", "path": "/account/feedback", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@feedback" }, { "method": "GET", "path": "/ajx/users", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@index", "filters": ["auth"] }, { "method": "POST", "path": "/ajx/users", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@register", "filters": ["throttle:10,1"] }, + { "method": "POST", "path": "/ajx/admin/users", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@adminCreate", "filters": ["auth", "throttle:30,1"] }, + { "method": "POST", "path": "/ajx/users/verify", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@verifyEmailByToken", "filters": ["throttle:10,1"] }, { "method": "GET", "path": "/ajx/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@show", "filters": ["auth"] }, { "method": "POST", "path": "/ajx/users/{id}/verify-email", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@verifyEmail", "filters": ["auth"] }, { "method": "PUT", "path": "/ajx/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@update", "filters": ["auth"] }, { "method": "PATCH", "path": "/ajx/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@update", "filters": ["auth"] }, { "method": "DELETE", "path": "/ajx/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@destroy", "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/feedback", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\FeedbackController@submit", "filters": ["auth", "tenant", "throttle:5,1"] }, - { "method": "GET", "path": "/ajx/feedback", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\FeedbackController@index", "filters": ["auth", "tenant"] }, - { "method": "GET", "path": "/ajx/feedback/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\FeedbackController@show", "filters": ["auth", "tenant"] }, - { "method": "PATCH", "path": "/ajx/feedback/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\FeedbackController@updateStatus", "filters": ["auth", "tenant"] }, - { "method": "GET", "path": "/ajx/profile", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showProfile", "filters": ["auth", "tenant"] }, { "method": "PUT", "path": "/ajx/profile", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updateProfile", "filters": ["auth", "tenant", "throttle:30,1"] }, @@ -48,7 +44,7 @@ { "method": "PUT", "path": "/ajx/notification-preferences", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updateNotifications", "filters": ["auth", "tenant", "throttle:30,1"] } ], - "emits": ["user.registered", "user.updated", "user.deleted", "feedback.submitted"], + "emits": ["user.registered", "user.updated", "user.deleted"], "listens": [], "documentation": "The User plugin — owns the user.management domain. CRUD + email verification + timing-safe, rate-limited credential verification over the GLOBAL central `users` identity table (identity is centralized, username/email globally unique, repository + outbox pinned to the central connection). Passwords are hashed via crypto.services (bcrypt, rehash-on-login); hashes and remember tokens never cross the API boundary. Writes are optimistic-locked (version); integration events use a transactional outbox drained by `user:outbox:relay`. Enabling publishes config/, database/ (migrations, seeder, factory) and resources/.", diff --git a/plugins/User/resources/views/emails/verify.php b/plugins/User/resources/views/emails/verify.php new file mode 100644 index 0000000..c243fff --- /dev/null +++ b/plugins/User/resources/views/emails/verify.php @@ -0,0 +1,49 @@ + + + + + + + + +
+ + +
+

Confirm your email

+

+ Thanks for signing up. Please confirm your email address to activate + your account. This link expires in 24 hours. +

+

+ + Verify email address + +

+

+ If the button doesn't work, copy this link into your browser:
+ + + +

+

+ If you didn't create this account, you can safely ignore this email. +

+
+
+ + diff --git a/plugins/User/resources/views/layouts/app.php b/plugins/User/resources/views/layouts/app.php index dd4c302..5362267 100644 --- a/plugins/User/resources/views/layouts/app.php +++ b/plugins/User/resources/views/layouts/app.php @@ -63,7 +63,6 @@ All users Create Settings - Feedback diff --git a/plugins/Validation/AbstractDto.php b/plugins/Validation/AbstractDto.php new file mode 100644 index 0000000..ed42aff --- /dev/null +++ b/plugins/Validation/AbstractDto.php @@ -0,0 +1,93 @@ + 'nullable|string|max:80']; + * } + * + * public static function fromRequest(Request $request): self + * { + * $v = static::validated($request); // throws 422 on bad input + * return new self(firstName: $v['firstName'] ?? null); + * } + * } + */ +abstract readonly class AbstractDto +{ + /** + * Field => rule(s), e.g. ['email' => 'required|email|max:150']. + * + * @return array> + */ + abstract protected static function rules(): array; + + /** + * Optional custom "field.rule" => message overrides. + * + * @return array + */ + protected static function messages(): array + { + return []; + } + + /** + * Validate the request (body + query merged) against rules(). Throws + * ValidationException (kernel 422) on failure; returns the validated map. + * + * @return array + */ + final protected static function validated(Request $request, ?Translator $translator = null): array + { + return static::validate($request->all(), $translator); + } + + /** + * Validate a raw input array — for callers that already have the map (jobs, + * CLI, sub-DTOs) and no Request. + * + * @param array $input + * @return array + */ + final protected static function validate(array $input, ?Translator $translator = null): array + { + return Validator::make($input, static::rules(), static::messages(), $translator)->validate(); + } + + /** + * Validate and RETURN the error map WITHOUT throwing — so a DTO can merge in + * domain-level errors (value-object / policy failures) and raise a single + * combined ValidationException. Empty array = shape is valid. + * + * @param array $input + * @return array> + */ + final protected static function collectErrors(array $input, ?Translator $translator = null): array + { + return Validator::make($input, static::rules(), static::messages(), $translator)->errors(); + } +} diff --git a/plugins/Validation/Provider.php b/plugins/Validation/Provider.php new file mode 100644 index 0000000..8e3b6cb --- /dev/null +++ b/plugins/Validation/Provider.php @@ -0,0 +1,77 @@ + */ + public function requires(): array + { + return []; + } + + /** @return list */ + public function exposes(): array + { + return []; + } + + public function register(ModuleContainer $container): void + { + // Nothing to bind — the Validator is used statically at the DTO boundary. + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + $config = $this->config(); + + // CI $ruleSets — register every rule-provider class. + foreach ($config['rulesets'] ?? [] as $ruleSet) { + Validator::extendWith($ruleSet); + } + + // CI rule groups — register each named {rules, messages} set. + foreach ($config['groups'] ?? [] as $name => $group) { + Validator::defineGroup($name, $group['rules'] ?? [], $group['messages'] ?? []); + } + } + + /** @return array{rulesets?: list, groups?: array} */ + private function config(): array + { + // Prefer a project override (config_path helper) when available; fall + // back to the plugin's shipped default. + $default = __DIR__ . '/config/validation.php'; + $path = function_exists('config_path') && is_file(config_path('validation.php')) + ? config_path('validation.php') + : $default; + + /** @var array $config */ + $config = is_file($path) ? require $path : []; + + return is_array($config) ? $config : []; + } +} diff --git a/plugins/Validation/README.md b/plugins/Validation/README.md new file mode 100644 index 0000000..03f29dd --- /dev/null +++ b/plugins/Validation/README.md @@ -0,0 +1,159 @@ +# Validation plugin + +A shared, dependency-free request-validation engine. It produces the kernel's +standard `ValidationException` (a 422 with `{ field: [messages] }`), so DTOs stop +hand-rolling `$errors[]` accumulation. + +- `Validator` — the rule engine (a plain autoloaded class; validation is DI-free + boundary logic, so it needs no container). +- `AbstractDto` — base class for request DTOs: declare `rules()`, call + `validated($request)`. +- `config/validation.php` — CodeIgniter's `Config\Validation` equivalent + (rule-sets + rule-groups), loaded once at boot by `Provider`. + +## 1. Validating a DTO + +```php +use Plugins\Validation\AbstractDto; + +final readonly class UpdateProfileDTO extends AbstractDto +{ + public function __construct(public ?string $firstName, public ?string $avatarUrl) {} + + protected static function rules(): array + { + return [ + 'firstName' => 'nullable|string|max:80', + 'avatarUrl' => 'nullable|http_url|max:500', + ]; + } + + protected static function messages(): array // optional per-rule overrides + { + return ['avatarUrl.http_url' => 'Avatar must be an http(s) URL.']; + } + + public static function fromRequest(Request $request): self + { + static::validated($request); // throws 422 on bad shape + return new self($request->input('firstName'), $request->input('avatarUrl')); + } +} +``` + +**Division of labour:** rules validate *shape* (required / type / length / +format); deep *domain* invariants stay in the value objects the DTO constructs. + +## 2. Built-in rules + +``` +required nullable string integer numeric boolean array +email url http_url timezone +min:n max:n between:a,b in:a,b,c regex:/.../ +same:field different:field confirmed enum:Class +``` + +## 3. Adding rules — three ways (all register ONCE at boot) + +### a. A single closure + +```php +Validator::extend('even', + fn($value) => (int) $value % 2 === 0, + 'The :field must be even.'); +// use it: 'quantity' => 'required|even' +``` + +### b. A rule-set CLASS (CodeIgniter style) + +Every public method becomes a rule; `messages()` supplies defaults. + +```php +final class CommonRules +{ + public function slug(mixed $v, ?string $p, array $data): bool + { + return is_string($v) && preg_match('/^[a-z0-9-]+$/', $v) === 1; + } + public function messages(): array + { + return ['slug' => 'The :field must be kebab-case.']; + } +} + +Validator::extendWith(CommonRules::class); // registers slug + any other methods +``` + +Shipped rule-set packs (both enabled by default via `config/validation.php`), +mirroring how CodeIgniter splits FormatRules / Rules / CreditCardRules: + +**`CommonRules`** — universal rules on top of the built-ins: + +```text +alpha alpha_num alpha_dash alpha_space alpha_numeric_punct ascii lowercase uppercase +digits[:n] digits_between:a,b is_natural is_natural_no_zero hex decimal[:p|:a,b] +multiple_of:n min_digits:n max_digits:n size:n gt:n gte:n lt:n lte:n +starts_with:… ends_with:… doesnt_start_with:… doesnt_end_with:… not_in:… +distinct list +uuid ulid slug username +ip ipv4 ipv6 mac_address domain +json base64 hex_color +date date_format:Y-m-d before:… after:… +accepted declined locale currency phone e164 +``` + +**`FinancialRules`** — money / payment fields: + +```text +luhn credit_card cvv iban bic +``` + +> Cross-field presence rules (`required_if` / `required_with`) are intentionally +> not provided — the engine skips value rules on absent fields, so they can't be +> expressed as extensions. Enforce those in the service layer. + +Rule method signature: `(mixed $value, ?string $param, array $data): bool`. +`$param` is the `:arg` in `rule:arg`; `$data` is the full input (cross-field rules). + +### c. Named rule GROUPS (CodeIgniter rule groups) + +Reusable `{rules, messages}` sets addressed by name: + +```php +Validator::defineGroup('login', [ + 'email' => 'required|email', + 'password' => 'required|string|min:8', +], ['email.required' => 'We need your email.']); + +Validator::group('login', $request->all())->validate(); +``` + +## 4. Configuration — `config/validation.php` + +The CI `Config\Validation` equivalent. The `Provider` reads it at boot and wires +it in — no core edits, no per-request cost. A project may override it by placing +its own `config/validation.php` (resolved via `config_path()`). + +```php +return [ + 'rulesets' => [ CommonRules::class ], // CI $ruleSets → extendWith() + 'groups' => [ // CI rule groups → defineGroup() + 'login' => ['rules' => [...], 'messages' => [...]], + ], +]; +``` + +## Message resolution order (per failed rule) + +1. DTO/`make()` override — `messages["{field}.{rule}"]` +2. I18n translator — `validation.{rule}` (when a `Translator` is passed) +3. built-in default, then a registered custom-rule default + +## Notes + +- `extend` / `extendWith` / `defineGroup` use a **process-wide static** registry: + register at bootstrap (a Provider `boot()` / project bootstrap), never + per-request — safe and cheap under OpenSwoole. `flushExtensions()` is a test + helper only. +- Unknown rules **pass** rather than fail hard, so a rule typo never 422s a whole + request surface by accident. diff --git a/plugins/Validation/Rules/CommonRules.php b/plugins/Validation/Rules/CommonRules.php new file mode 100644 index 0000000..58e0119 --- /dev/null +++ b/plugins/Validation/Rules/CommonRules.php @@ -0,0 +1,473 @@ + $data): bool + */ +final class CommonRules +{ + // ── character classes ──────────────────────────────────────────────────── + + /** Unicode letters only. */ + public function alpha(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^\p{L}+$/u', $v) === 1; + } + + /** Unicode letters and numbers. */ + public function alpha_num(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[\p{L}\p{N}]+$/u', $v) === 1; + } + + /** Letters, numbers, dashes and underscores (slug-safe identifiers). */ + public function alpha_dash(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[\p{L}\p{N}_-]+$/u', $v) === 1; + } + + /** Letters and spaces (human names). */ + public function alpha_space(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[\p{L} ]+$/u', $v) === 1; + } + + /** Letters, numbers, spaces and common punctuation (free text, CI parity). */ + public function alpha_numeric_punct(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) + && \preg_match('/^[\p{L}\p{N} ~!#$%&*\-_+=|:.;,?@\'"\/()\[\]{}]+$/u', $v) === 1; + } + + /** 7-bit ASCII only. */ + public function ascii(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[\x00-\x7F]*$/', $v) === 1; + } + + /** Already lowercase. */ + public function lowercase(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \mb_strtolower($v) === $v; + } + + /** Already uppercase. */ + public function uppercase(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \mb_strtoupper($v) === $v; + } + + // ── numbers / sizes ────────────────────────────────────────────────────── + + /** All digits; with `digits:n`, exactly n digits. */ + public function digits(mixed $v, ?string $p, array $d): bool + { + $s = (string) $v; + if (\ctype_digit($s) === false) { + return false; + } + return $p === null || \mb_strlen($s) === (int) $p; + } + + /** `digits_between:a,b` — all digits, length within [a,b]. */ + public function digits_between(mixed $v, ?string $p, array $d): bool + { + $s = (string) $v; + if (\ctype_digit($s) === false) { + return false; + } + [$a, $b] = \array_pad(\explode(',', (string) $p), 2, '0'); + $len = \mb_strlen($s); + return $len >= (int) $a && $len <= (int) $b; + } + + /** `size:n` — string length / array count / number equals n. */ + public function size(mixed $v, ?string $p, array $d): bool + { + return $this->measure($v) === (float) $p; + } + + /** `gt:n` — numeric/size strictly greater than n. */ + public function gt(mixed $v, ?string $p, array $d): bool + { + return $this->measure($v) > (float) $p; + } + + /** `gte:n` — numeric/size greater than or equal to n. */ + public function gte(mixed $v, ?string $p, array $d): bool + { + return $this->measure($v) >= (float) $p; + } + + /** `lt:n` — numeric/size strictly less than n. */ + public function lt(mixed $v, ?string $p, array $d): bool + { + return $this->measure($v) < (float) $p; + } + + /** `lte:n` — numeric/size less than or equal to n. */ + public function lte(mixed $v, ?string $p, array $d): bool + { + return $this->measure($v) <= (float) $p; + } + + /** Non-negative integer (0, 1, 2, …). */ + public function is_natural(mixed $v, ?string $p, array $d): bool + { + return \ctype_digit((string) $v); + } + + /** Positive integer (1, 2, 3, …). */ + public function is_natural_no_zero(mixed $v, ?string $p, array $d): bool + { + return \ctype_digit((string) $v) && (int) $v > 0; + } + + /** Hexadecimal string. */ + public function hex(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && $v !== '' && \ctype_xdigit($v); + } + + /** + * Decimal number. `decimal` = any; `decimal:2` = exactly 2 places; + * `decimal:1,4` = between 1 and 4 places. + */ + public function decimal(mixed $v, ?string $p, array $d): bool + { + if (\preg_match('/^-?\d+(?:\.(\d+))?$/', (string) $v, $m) !== 1) { + return false; + } + if ($p === null) { + return true; + } + $places = isset($m[1]) ? \strlen($m[1]) : 0; + [$min, $max] = \array_pad(\explode(',', $p), 2, null); + $max ??= $min; + + return $places >= (int) $min && $places <= (int) $max; + } + + /** `multiple_of:n` — numeric and evenly divisible by n. */ + public function multiple_of(mixed $v, ?string $p, array $d): bool + { + if (!\is_numeric($v) || !\is_numeric($p) || (float) $p === 0.0) { + return false; + } + return \fmod((float) $v, (float) $p) === 0.0; + } + + /** `min_digits:n` — numeric with at least n digits. */ + public function min_digits(mixed $v, ?string $p, array $d): bool + { + $digits = \preg_replace('/\D/', '', (string) $v); + return $digits !== '' && \strlen($digits) >= (int) $p; + } + + /** `max_digits:n` — numeric with at most n digits. */ + public function max_digits(mixed $v, ?string $p, array $d): bool + { + $digits = \preg_replace('/\D/', '', (string) $v); + return \strlen((string) $digits) <= (int) $p; + } + + // ── strings / membership ───────────────────────────────────────────────── + + /** `starts_with:a,b,c` — begins with any of the listed prefixes. */ + public function starts_with(mixed $v, ?string $p, array $d): bool + { + $s = (string) $v; + foreach (\explode(',', (string) $p) as $needle) { + if ($needle !== '' && \str_starts_with($s, $needle)) { + return true; + } + } + return false; + } + + /** `ends_with:a,b,c` — ends with any of the listed suffixes. */ + public function ends_with(mixed $v, ?string $p, array $d): bool + { + $s = (string) $v; + foreach (\explode(',', (string) $p) as $needle) { + if ($needle !== '' && \str_ends_with($s, $needle)) { + return true; + } + } + return false; + } + + /** `doesnt_start_with:a,b` — begins with none of the listed prefixes. */ + public function doesnt_start_with(mixed $v, ?string $p, array $d): bool + { + return $this->starts_with($v, $p, $d) === false; + } + + /** `doesnt_end_with:a,b` — ends with none of the listed suffixes. */ + public function doesnt_end_with(mixed $v, ?string $p, array $d): bool + { + return $this->ends_with($v, $p, $d) === false; + } + + /** `not_in:a,b,c` — value is NOT one of the listed options. */ + public function not_in(mixed $v, ?string $p, array $d): bool + { + return \in_array((string) $v, \explode(',', (string) $p), true) === false; + } + + // ── arrays ─────────────────────────────────────────────────────────────── + + /** Array whose values are all unique. */ + public function distinct(mixed $v, ?string $p, array $d): bool + { + return \is_array($v) && \count($v) === \count(\array_unique($v, SORT_REGULAR)); + } + + /** Array that is a sequential list (0,1,2… keys), not a map. */ + public function list(mixed $v, ?string $p, array $d): bool + { + return \is_array($v) && \array_is_list($v); + } + + // ── identifiers ────────────────────────────────────────────────────────── + + /** RFC 4122 UUID (any version). */ + public function uuid(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) + && \preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $v) === 1; + } + + /** ULID — 26 Crockford base32 chars. */ + public function ulid(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i', $v) === 1; + } + + /** lowercase kebab-case slug. */ + public function slug(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $v) === 1; + } + + /** Username — 3+ chars of letters, numbers, underscore. */ + public function username(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[A-Za-z0-9_]{3,}$/', $v) === 1; + } + + // ── network ────────────────────────────────────────────────────────────── + + /** Any IP address (v4 or v6). */ + public function ip(mixed $v, ?string $p, array $d): bool + { + return \filter_var($v, FILTER_VALIDATE_IP) !== false; + } + + public function ipv4(mixed $v, ?string $p, array $d): bool + { + return \filter_var($v, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; + } + + public function ipv6(mixed $v, ?string $p, array $d): bool + { + return \filter_var($v, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; + } + + /** MAC address. */ + public function mac_address(mixed $v, ?string $p, array $d): bool + { + return \filter_var($v, FILTER_VALIDATE_MAC) !== false; + } + + /** DNS hostname / domain. */ + public function domain(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) + && \preg_match('/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $v) === 1; + } + + // ── formats ────────────────────────────────────────────────────────────── + + /** Valid JSON string. */ + public function json(mixed $v, ?string $p, array $d): bool + { + if (\is_string($v) === false || $v === '') { + return false; + } + \json_decode($v); + return \json_last_error() === JSON_ERROR_NONE; + } + + /** Base64-encoded string. */ + public function base64(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \base64_decode($v, true) !== false; + } + + /** CSS hex colour (#rgb or #rrggbb). */ + public function hex_color(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i', $v) === 1; + } + + // ── dates ──────────────────────────────────────────────────────────────── + + /** Any parseable date/time. */ + public function date(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && $v !== '' && \strtotime($v) !== false; + } + + /** `date_format:Y-m-d` — matches the exact format. */ + public function date_format(mixed $v, ?string $p, array $d): bool + { + if (\is_string($v) === false || $p === null) { + return false; + } + $dt = \DateTimeImmutable::createFromFormat('!' . $p, $v); + return $dt !== false && $dt->format($p) === $v; + } + + /** `before:2030-01-01` (or `before:today`) — strictly earlier. */ + public function before(mixed $v, ?string $p, array $d): bool + { + $a = \strtotime((string) $v); + $b = \strtotime((string) $p); + return $a !== false && $b !== false && $a < $b; + } + + /** `after:2000-01-01` (or `after:today`) — strictly later. */ + public function after(mixed $v, ?string $p, array $d): bool + { + $a = \strtotime((string) $v); + $b = \strtotime((string) $p); + return $a !== false && $b !== false && $a > $b; + } + + // ── booleans / acceptance ──────────────────────────────────────────────── + + /** Truthy consent: yes / on / 1 / true. */ + public function accepted(mixed $v, ?string $p, array $d): bool + { + return \in_array($v, ['yes', 'on', '1', 1, true, 'true'], true); + } + + /** Falsy: no / off / 0 / false. */ + public function declined(mixed $v, ?string $p, array $d): bool + { + return \in_array($v, ['no', 'off', '0', 0, false, 'false'], true); + } + + // ── locale / money / contact ───────────────────────────────────────────── + + /** ll_CC locale tag, e.g. en_US. */ + public function locale(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[a-z]{2}_[A-Z]{2}$/', $v) === 1; + } + + /** ISO 4217 currency code, e.g. UGX. */ + public function currency(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^[A-Z]{3}$/', $v) === 1; + } + + /** Loose phone: optional +, 7–15 digits. */ + public function phone(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^\+?[0-9]{7,15}$/', $v) === 1; + } + + /** Strict E.164: leading +, 1–15 digits, no leading zero. */ + public function e164(mixed $v, ?string $p, array $d): bool + { + return \is_string($v) && \preg_match('/^\+[1-9]\d{1,14}$/', $v) === 1; + } + + /** @return array per-rule default messages (:field / :param). */ + public function messages(): array + { + return [ + 'alpha' => 'The :field field may only contain letters.', + 'alpha_num' => 'The :field field may only contain letters and numbers.', + 'alpha_dash' => 'The :field field may only contain letters, numbers, dashes and underscores.', + 'alpha_space' => 'The :field field may only contain letters and spaces.', + 'alpha_numeric_punct' => 'The :field field contains an invalid character.', + 'ascii' => 'The :field field may only contain ASCII characters.', + 'lowercase' => 'The :field field must be lowercase.', + 'uppercase' => 'The :field field must be uppercase.', + 'digits' => 'The :field field must be all digits.', + 'digits_between' => 'The :field field has an invalid number of digits.', + 'is_natural' => 'The :field field must be a non-negative whole number.', + 'is_natural_no_zero' => 'The :field field must be a positive whole number.', + 'hex' => 'The :field field must be hexadecimal.', + 'decimal' => 'The :field field must be a decimal number.', + 'multiple_of' => 'The :field field must be a multiple of :param.', + 'min_digits' => 'The :field field must have at least :param digits.', + 'max_digits' => 'The :field field must not exceed :param digits.', + 'size' => 'The :field field must be of size :param.', + 'gt' => 'The :field field must be greater than :param.', + 'gte' => 'The :field field must be at least :param.', + 'lt' => 'The :field field must be less than :param.', + 'lte' => 'The :field field must not be greater than :param.', + 'starts_with' => 'The :field field has an invalid prefix.', + 'ends_with' => 'The :field field has an invalid suffix.', + 'doesnt_start_with' => 'The :field field has a forbidden prefix.', + 'doesnt_end_with' => 'The :field field has a forbidden suffix.', + 'not_in' => 'The selected :field is invalid.', + 'enum' => 'The selected :field is invalid.', + 'distinct' => 'The :field field has duplicate values.', + 'list' => 'The :field field must be a list.', + 'uuid' => 'The :field field must be a valid UUID.', + 'ulid' => 'The :field field must be a valid ULID.', + 'slug' => 'The :field field must be a lowercase kebab-case slug.', + 'username' => 'The :field field must be 3+ letters, numbers or underscores.', + 'ip' => 'The :field field must be a valid IP address.', + 'ipv4' => 'The :field field must be a valid IPv4 address.', + 'ipv6' => 'The :field field must be a valid IPv6 address.', + 'mac_address' => 'The :field field must be a valid MAC address.', + 'domain' => 'The :field field must be a valid domain.', + 'json' => 'The :field field must be valid JSON.', + 'base64' => 'The :field field must be valid base64.', + 'hex_color' => 'The :field field must be a valid hex colour.', + 'date' => 'The :field field must be a valid date.', + 'date_format' => 'The :field field does not match the required format.', + 'before' => 'The :field field must be a date before :param.', + 'after' => 'The :field field must be a date after :param.', + 'accepted' => 'The :field field must be accepted.', + 'declined' => 'The :field field must be declined.', + 'locale' => 'The :field field must be a locale like en_US.', + 'currency' => 'The :field field must be a 3-letter ISO 4217 code.', + 'phone' => 'The :field field must be 7–15 digits (optional leading +).', + 'e164' => 'The :field field must be a valid E.164 phone number.', + ]; + } + + /** String length / array count / numeric value, used by size/gt/gte/lt/lte. */ + private function measure(mixed $value): float + { + if (\is_numeric($value)) { + return (float) $value; + } + if (\is_array($value)) { + return (float) \count($value); + } + return (float) \mb_strlen((string) $value); + } +} diff --git a/plugins/Validation/Rules/FinancialRules.php b/plugins/Validation/Rules/FinancialRules.php new file mode 100644 index 0000000..6cd52c0 --- /dev/null +++ b/plugins/Validation/Rules/FinancialRules.php @@ -0,0 +1,100 @@ + $data): bool + */ +final class FinancialRules +{ + /** Raw Luhn (mod-10) checksum over the digits in the value. */ + public function luhn(mixed $v, ?string $p, array $d): bool + { + $digits = \preg_replace('/\D/', '', (string) $v); + + return $digits !== '' && self::passesLuhn($digits); + } + + /** Credit-card number: 12–19 digits AND a valid Luhn checksum. */ + public function credit_card(mixed $v, ?string $p, array $d): bool + { + $digits = \preg_replace('/\D/', '', (string) $v); + $len = \strlen((string) $digits); + + return $len >= 12 && $len <= 19 && self::passesLuhn($digits); + } + + /** CVV / CVC — 3 or 4 digits. */ + public function cvv(mixed $v, ?string $p, array $d): bool + { + return \preg_match('/^[0-9]{3,4}$/', (string) $v) === 1; + } + + /** IBAN — format + ISO 7064 mod-97 checksum. */ + public function iban(mixed $v, ?string $p, array $d): bool + { + $iban = \strtoupper(\preg_replace('/\s+/', '', (string) $v)); + if (\preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/', $iban) !== 1) { + return false; + } + + // Move the first 4 chars to the end, then replace letters with 10–35. + $rearranged = \substr($iban, 4) . \substr($iban, 0, 4); + $numeric = ''; + foreach (\str_split($rearranged) as $char) { + $numeric .= \ctype_alpha($char) ? (string) (\ord($char) - 55) : $char; + } + + // mod 97 over a long numeric string, chunked to stay in int range. + $remainder = 0; + foreach (\str_split($numeric, 7) as $chunk) { + $remainder = (int) (($remainder . $chunk) % 97); + } + + return $remainder === 1; + } + + /** SWIFT/BIC — 8 or 11 alphanumerics (AAAA BB CC [DDD]). */ + public function bic(mixed $v, ?string $p, array $d): bool + { + return \preg_match('/^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/', \strtoupper((string) $v)) === 1; + } + + /** @return array */ + public function messages(): array + { + return [ + 'luhn' => 'The :field field failed its checksum.', + 'credit_card' => 'The :field field must be a valid card number.', + 'cvv' => 'The :field field must be a 3 or 4 digit security code.', + 'iban' => 'The :field field must be a valid IBAN.', + 'bic' => 'The :field field must be a valid BIC/SWIFT code.', + ]; + } + + /** Luhn (mod-10) over a pure-digit string. */ + private static function passesLuhn(string $digits): bool + { + $sum = 0; + $alt = false; + for ($i = \strlen($digits) - 1; $i >= 0; $i--) { + $n = (int) $digits[$i]; + if ($alt) { + $n *= 2; + if ($n > 9) { + $n -= 9; + } + } + $sum += $n; + $alt = !$alt; + } + + return $sum % 10 === 0; + } +} diff --git a/plugins/Validation/Validator.php b/plugins/Validation/Validator.php index ef6ce86..3e75133 100644 --- a/plugins/Validation/Validator.php +++ b/plugins/Validation/Validator.php @@ -28,14 +28,39 @@ * * Supported rules: * required, nullable, string, integer, numeric, boolean, array, email, url, - * min:n, max:n, between:a,b, in:a,b,c, regex:/.../, same:field, different:field, - * confirmed + * http_url, timezone, min:n, max:n, between:a,b, in:a,b,c, regex:/.../, + * same:field, different:field, confirmed + * + * Custom rules — register once at bootstrap: + * Validator::extend('kebab', + * fn($v) => is_string($v) && preg_match('/^[a-z0-9-]+$/', $v) === 1, + * 'The :field must be kebab-case.'); + * // then: 'slug' => 'required|kebab' */ final class Validator { /** @var array> */ private array $errors = []; + /** + * Custom rules registered via extend(). Process-wide (static) so a rule is + * available to EVERY validator instance without re-registering. + * + * @var array $data): bool> + */ + private static array $extensions = []; + + /** Default messages for custom rules, keyed by rule name. @var array */ + private static array $extensionMessages = []; + + /** + * Named rule GROUPS (CodeIgniter-style): a reusable {rules, messages} set + * addressed by name via group(). Populated from config/validation.php. + * + * @var array>, messages: array}> + */ + private static array $groups = []; + /** Built-in English defaults; :field and rule params are interpolated. */ private const DEFAULTS = [ 'required' => 'The :field field is required.', @@ -46,6 +71,9 @@ final class Validator 'array' => 'The :field field must be an array.', 'email' => 'The :field field must be a valid email address.', 'url' => 'The :field field must be a valid URL.', + 'http_url' => 'The :field field must be a valid http(s) URL.', + 'timezone' => 'The :field field must be a valid timezone.', + 'enum' => 'The selected :field is invalid.', 'min' => 'The :field field must be at least :min.', 'max' => 'The :field field must not be greater than :max.', 'between' => 'The :field field must be between :min and :max.', @@ -79,6 +107,88 @@ public static function make(array $data, array $rules, array $messages = [], ?Tr return new self($data, $rules, $messages, $translator); } + /** + * Register a CUSTOM rule. + * + * The callback receives the field value, the optional `:param` (e.g. the + * `5` in `starts_with:5`), and the full input map (for cross-field rules). + * Return true to pass, false to fail. Use it like any built-in rule: + * `'slug' => 'required|kebab'`. + * + * Call this ONCE at bootstrap (a Provider::boot / project bootstrap) — the + * registry is static and process-wide, so registering per-request is both + * wasteful and unsafe under OpenSwoole. Registration is idempotent (same + * name overwrites). + * + * @param callable(mixed $value, ?string $param, array $data): bool $validator + * @param string|null $message Default message; supports :field and :param. + */ + public static function extend(string $rule, callable $validator, ?string $message = null): void + { + self::$extensions[$rule] = $validator; + if ($message !== null) { + self::$extensionMessages[$rule] = $message; + } + } + + /** + * Register a RULE-SET class (CodeIgniter-style): every public method on the + * class becomes a rule named after the method. Each method has the signature + * `(mixed $value, ?string $param, array $data): bool`. An + * optional `messages(): array` method supplies per-rule + * default messages. Register once at bootstrap. + * + * @param object|class-string $ruleSet instance or class-string to construct + */ + public static function extendWith(object|string $ruleSet): void + { + $instance = is_string($ruleSet) ? new $ruleSet() : $ruleSet; + + $messages = method_exists($instance, 'messages') ? $instance->messages() : []; + + foreach ((new \ReflectionClass($instance))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + $name = $method->getName(); + // Skip framework hooks + magic methods — only rule methods register. + if ($name === 'messages' || str_starts_with($name, '__')) { + continue; + } + self::extend($name, $instance->{$name}(...), $messages[$name] ?? null); + } + } + + /** + * Define a reusable named rule GROUP (CodeIgniter rule groups). Address it + * later with group(). Typically populated from config/validation.php. + * + * @param array> $rules + * @param array $messages + */ + public static function defineGroup(string $name, array $rules, array $messages = []): void + { + self::$groups[$name] = ['rules' => $rules, 'messages' => $messages]; + } + + /** + * Build a validator from a previously defined named group. + * + * @param array $data + */ + public static function group(string $name, array $data, ?Translator $translator = null): self + { + $group = self::$groups[$name] + ?? throw new \InvalidArgumentException("Unknown validation group [{$name}]."); + + return new self($data, $group['rules'], $group['messages'], $translator); + } + + /** Drop all custom rules + groups — test helper; never needed on the hot path. */ + public static function flushExtensions(): void + { + self::$extensions = []; + self::$extensionMessages = []; + self::$groups = []; + } + public function fails(): bool { $this->run(); @@ -166,6 +276,9 @@ private function validateRule(string $rule, mixed $value, ?string $param, string 'array' => is_array($value), 'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false, 'url' => filter_var($value, FILTER_VALIDATE_URL) !== false, + 'http_url' => self::isHttpUrl($value), + 'timezone' => is_string($value) && in_array($value, timezone_identifiers_list(), true), + 'enum' => self::isEnumValue($value, $param), 'min' => $this->size($value) >= (float) $param, 'max' => $this->size($value) <= (float) $param, 'between' => $this->between($value, $param), @@ -174,7 +287,7 @@ private function validateRule(string $rule, mixed $value, ?string $param, string 'same' => $value === ($this->data[$param] ?? null), 'different' => $value !== ($this->data[$param] ?? null), 'confirmed' => $value === ($this->data[$field . '_confirmation'] ?? null), - default => true, // unknown rule — pass rather than fail hard + default => $this->runExtension($rule, $value, $param), }; return $ok ? null : $this->replacements($rule, $param); @@ -205,6 +318,43 @@ private function replacements(string $rule, ?string $param): array return $replace; } + /** Dispatch a rule not known built-in to a registered custom rule (unknown → pass). */ + private function runExtension(string $rule, mixed $value, ?string $param): bool + { + $ext = self::$extensions[$rule] ?? null; + + // Unknown rule: pass rather than fail hard (a typo shouldn't 422 the world). + return $ext === null ? true : $ext($value, $param, $this->data); + } + + /** True when $value is a valid case of the (backed or pure) enum named in $param. */ + private static function isEnumValue(mixed $value, ?string $enum): bool + { + if ($enum === null || enum_exists($enum) === false) { + return false; + } + if (method_exists($enum, 'tryFrom')) { // backed enum + return (is_string($value) || is_int($value)) && $enum::tryFrom($value) !== null; + } + foreach ($enum::cases() as $case) { // pure enum → match name + if ($case->name === $value) { + return true; + } + } + return false; + } + + /** True for a syntactically valid absolute http/https URL. */ + private static function isHttpUrl(mixed $value): bool + { + if (!is_string($value) || filter_var($value, FILTER_VALIDATE_URL) === false) { + return false; + } + $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME)); + + return $scheme === 'http' || $scheme === 'https'; + } + private function present(mixed $value): bool { if (is_array($value)) { @@ -250,7 +400,11 @@ private function message(string $field, string $rule, array $replace): string return $this->translator->get("validation.{$rule}", $replace); } - return $this->interpolate(self::DEFAULTS[$rule] ?? "The {$field} field is invalid.", $replace); + $default = self::DEFAULTS[$rule] + ?? self::$extensionMessages[$rule] + ?? "The {$field} field is invalid."; + + return $this->interpolate($default, $replace); } /** @param array $replace */ diff --git a/plugins/Validation/config/validation.php b/plugins/Validation/config/validation.php new file mode 100644 index 0000000..c08872e --- /dev/null +++ b/plugins/Validation/config/validation.php @@ -0,0 +1,39 @@ + [ + CommonRules::class, + FinancialRules::class, + ], + + // CI rule groups — name => ['rules' => [...], 'messages' => [...]]. + 'groups' => [ + // 'login' => [ + // 'rules' => [ + // 'email' => 'required|email', + // 'password' => 'required|string|min:8', + // ], + // 'messages' => [ + // 'email.required' => 'We need your email to sign you in.', + // ], + // ], + ], +]; diff --git a/plugins/Validation/module.json b/plugins/Validation/module.json new file mode 100644 index 0000000..eafcc9f --- /dev/null +++ b/plugins/Validation/module.json @@ -0,0 +1,17 @@ +{ + "name": "validation", + "version": "1.0.0", + "solves": "validation.rules", + "type": "module", + + "requires": [], + "exposes": [], + + "routes": [], + "emits": [], + "listens": [], + + "documentation": "The Validation plugin — a shared, dependency-free request-validation engine (Plugins\\Validation\\Validator) that produces the kernel's standard 422 ValidationException. DTOs extend Plugins\\Validation\\AbstractDto and declare rules() instead of hand-rolling error accumulation. Rules use Laravel-style strings ('required|email|max:150'); shape lives in rules, deep domain invariants stay in value objects. Extend with single closures (Validator::extend) or CodeIgniter-style rule-set CLASSES (Validator::extendWith) and reusable named rule GROUPS (Validator::group), all configured once at boot from config/validation.php. The Provider only loads that config; the Validator is used statically at the DTO boundary (no container, no per-request cost).", + + "config": [] +} diff --git a/templates/app/bootstrap/app.php b/templates/app/bootstrap/app.php index 695bf57..fe5dc28 100644 --- a/templates/app/bootstrap/app.php +++ b/templates/app/bootstrap/app.php @@ -87,6 +87,7 @@ use Plugins\Commands\Provider as CommandsProvider; use Plugins\Storage\Provider as StorageProvider; use Plugins\HttpClient\Provider as HttpClientProvider; +use Plugins\Validation\Provider as ValidationProvider; use Plugins\Session\Provider as SessionProvider; use Plugins\Cookie\Provider as CookieProvider; use Plugins\RedisCache\Provider as RedisCacheProvider; @@ -195,6 +196,17 @@ EnqueueIndexNowListener::class => static fn($c) => new EnqueueIndexNowListener( $c->make(QueuePort::class), ), + + // ── When you enable the User + Tenancy plugins ─────────────────────────── + // The User plugin subscribes ProvisionTenantProfileListener to user.registered + // to write the per-tenant user_profiles row. The EventBus resolves listeners + // from the CoreContainer, so bind it here WITH Tenancy's connection resolver + // (same pattern as the SEO listener above). Left unbound it safely no-ops. + // + // \Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener::class + // => static fn($c) => new \Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener( + // $c->make(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class), + // ), ]; if (filter_var($env('DB_POOL_ENABLED', 'false'), FILTER_VALIDATE_BOOL)) { @@ -268,6 +280,12 @@ // locale negotiation, and the translator used by modules and views. I18nProvider::class, + // Validation (solves: validation.rules) — the shared request-validation + // engine. Its boot() loads config/validation.php and registers the + // CommonRules + FinancialRules packs. DTOs extend Plugins\Validation\ + // AbstractDto; built-in rules work without this, the packs need it. + ValidationProvider::class, + // Database (solves: database.query) — the multi-driver database stack: // the DatabasePort adapter, the pooled adapter that borrows from the // ConnectionPool, and connection/schema management. @@ -296,6 +314,14 @@ // JSON-LD, robots, IndexNow. Exposes SeoServiceContract + the /api/seo/* // routes. Needs http.client (above) for its network actions. SiteSeoModule::class, + + // Identity stack (enable together in an app that needs accounts): + // \Plugins\User\Provider::class, // user.management (identity + settings) + // \Plugins\Feedback\Provider::class, // feedback.management (/ajx/feedback) + // \Plugins\Auth\Provider::class, // auth.identity (login/tokens) + // \Plugins\Tenancy\Provider::class, // tenancy.routing (multi-tenant) + // The User plugin queues a verification email on signup ONLY when a + // MailPort is bound in withPorts() above (else it is skipped). ]) // ESSENTIAL modules: registered into EVERY request container regardless of diff --git a/tests/Unit/Plugins/Auth/Support/FakeUserService.php b/tests/Unit/Plugins/Auth/Support/FakeUserService.php index 09cce54..b787f23 100644 --- a/tests/Unit/Plugins/Auth/Support/FakeUserService.php +++ b/tests/Unit/Plugins/Auth/Support/FakeUserService.php @@ -76,6 +76,8 @@ public function clearRememberToken(string $userId): void {} public function list(ListUsersQuery $query): UserPage { throw new \BadMethodCallException(); } public function register(RegisterUserDTO $dto): UserDTO { throw new \BadMethodCallException(); } + public function registerPublic(RegisterUserDTO $dto): string { throw new \BadMethodCallException(); } + public function verifyEmailByToken(string $token): bool { throw new \BadMethodCallException(); } public function update(string $id, UpdateUserDTO $dto): ?UserDTO { throw new \BadMethodCallException(); } public function verifyEmail(string $id, VerifyEmailDTO $dto): ?UserDTO { throw new \BadMethodCallException(); } public function delete(string $id): bool { throw new \BadMethodCallException(); } diff --git a/tests/Unit/Plugins/Mail/MailerTest.php b/tests/Unit/Plugins/Mail/MailerTest.php new file mode 100644 index 0000000..d0eedaf --- /dev/null +++ b/tests/Unit/Plugins/Mail/MailerTest.php @@ -0,0 +1,142 @@ +transport = new ArrayTransport(); + $this->mailer = new Mailer( + $this->transport, + new MimeBuilder(), + fromEmail: 'no-reply@shop.test', + fromName: 'Shop', + ); + } + + public function test_builds_nested_multipart_with_attachment_and_inline_image(): void + { + $message = $this->mailer->message() + ->to('customer@example.com', 'Cust') + ->subject('Welcome') + ->html('

Hi

') + ->embedData('PNG', 'logo', 'logo.png', 'image/png') + ->attachData("a,b\n1,2", 'report.csv', 'text/csv') + ->priority(Priority::High); + + $this->mailer->dispatch($message); + $mime = $this->transport->last()['mime']; + + $this->assertStringContainsString('multipart/mixed', $mime); + $this->assertStringContainsString('multipart/related', $mime); + $this->assertStringContainsString('multipart/alternative', $mime); + $this->assertStringContainsString('Content-ID: ', $mime); + $this->assertStringContainsString('filename="report.csv"', $mime); + $this->assertStringContainsString('X-Priority: 1', $mime); + } + + public function test_bcc_recipients_are_delivered_but_never_appear_in_headers(): void + { + $this->mailer->dispatch( + $this->mailer->message()->to('a@example.com')->bcc('secret@example.com')->subject('x')->text('y'), + ); + + $sent = $this->transport->last(); + $this->assertContains('secret@example.com', $sent['recipients']); + $this->assertStringNotContainsStringIgnoringCase('Bcc:', $sent['mime']); + } + + public function test_non_ascii_subject_is_mime_encoded(): void + { + $this->mailer->dispatch($this->mailer->message()->to('a@example.com')->subject('Wëlcome ☕')->text('hi')); + + $this->assertStringContainsString('Subject: =?UTF-8?B?', $this->transport->last()['mime']); + } + + public function test_html_only_gets_an_auto_generated_plain_text_alternative(): void + { + $this->mailer->dispatch($this->mailer->message()->to('a@example.com')->subject('x')->html('

Hi there

')); + $mime = $this->transport->last()['mime']; + + $this->assertStringContainsString('text/plain', $mime); + $this->assertStringContainsString('text/html', $mime); + } + + public function test_crlf_in_address_is_rejected_header_injection_guard(): void + { + $this->expectException(MailException::class); + $this->mailer->message()->to("victim@example.com\r\nBcc: attacker@evil.com"); + } + + public function test_invalid_email_is_rejected(): void + { + $this->expectException(MailException::class); + $this->mailer->message()->to('not-an-email'); + } + + public function test_mailport_view_path_treats_view_as_raw_html_without_a_renderer(): void + { + $this->mailer->send('a@example.com', 'Hi', '

raw

'); + $mime = $this->transport->last()['mime']; + + $this->assertStringContainsString('To: a@example.com', $mime); + $this->assertStringContainsString('Subject: Hi', $mime); + $this->assertStringContainsString('

raw

', quoted_printable_decode($this->bodyOf($mime))); + } + + public function test_message_without_recipient_throws(): void + { + $this->expectException(MailException::class); + $this->mailer->dispatch($this->mailer->message()->subject('x')->text('y')); + } + + public function test_no_header_line_exceeds_the_rfc_hard_limit(): void + { + $message = $this->mailer->message()->subject('x')->text('y'); + for ($i = 0; $i < 40; $i++) { // a long recipient list + $message->to("user{$i}.longlocalpart@example-domain.test"); + } + $this->mailer->dispatch($message); + + $headerBlock = explode("\r\n\r\n", $this->transport->last()['mime'], 2)[0]; + foreach (explode("\r\n", $headerBlock) as $line) { + $this->assertLessThanOrEqual(998, strlen($line), 'Header line exceeds RFC 5322 limit.'); + } + // Folded continuation lines begin with whitespace. + $this->assertStringContainsString("\r\n ", $headerBlock); + } + + public function test_long_non_ascii_subject_is_chunked_into_multiple_encoded_words(): void + { + $this->mailer->dispatch( + $this->mailer->message()->to('a@example.com')->subject(str_repeat('café ', 40))->text('y'), + ); + $headerBlock = explode("\r\n\r\n", $this->transport->last()['mime'], 2)[0]; + + foreach (explode("\r\n", $headerBlock) as $line) { + $this->assertLessThanOrEqual(998, strlen($line)); + } + $this->assertGreaterThan(1, substr_count($headerBlock, '=?UTF-8?B?'), 'Subject should split into multiple encoded-words.'); + } + + private function bodyOf(string $mime): string + { + return explode("\r\n\r\n", $mime, 2)[1] ?? ''; + } +} diff --git a/tests/Unit/Plugins/User/Support/FakeUserStore.php b/tests/Unit/Plugins/User/Support/FakeUserStore.php index b33811d..472b594 100644 --- a/tests/Unit/Plugins/User/Support/FakeUserStore.php +++ b/tests/Unit/Plugins/User/Support/FakeUserStore.php @@ -56,6 +56,19 @@ public function findByRememberToken(string $tokenHash): ?User return null; } + public function findByVerificationTokenHash(string $tokenHash): ?User + { + if ($tokenHash === '') { + return null; + } + foreach ($this->byId as $user) { + if (hash_equals((string) $user->emailVerificationTokenHash(), $tokenHash)) { + return $user; + } + } + return null; + } + public function updateRememberToken(string $userId, ?string $tokenHash): void { if ($tokenHash === null) { diff --git a/tests/Unit/Plugins/Validation/ValidatorTest.php b/tests/Unit/Plugins/Validation/ValidatorTest.php index 4d13a94..ccc94b9 100644 --- a/tests/Unit/Plugins/Validation/ValidatorTest.php +++ b/tests/Unit/Plugins/Validation/ValidatorTest.php @@ -99,4 +99,104 @@ public function test_custom_message_override_wins(): void $this->assertSame('Bad address.', $errors['email'][0]); } + + protected function tearDown(): void + { + Validator::flushExtensions(); + } + + public function test_builtin_http_url_and_timezone_rules(): void + { + $ok = Validator::make( + ['site' => 'https://ex.com', 'tz' => 'Africa/Kampala'], + ['site' => 'http_url', 'tz' => 'timezone'], + ); + $this->assertTrue($ok->passes()); + + $bad = Validator::make( + ['site' => 'ftp://ex.com', 'tz' => 'Mars/Olympus'], + ['site' => 'http_url', 'tz' => 'timezone'], + )->errors(); + $this->assertArrayHasKey('site', $bad); + $this->assertArrayHasKey('tz', $bad); + } + + public function test_extend_registers_a_closure_rule(): void + { + Validator::extend('even', static fn($v): bool => (int) $v % 2 === 0, 'The :field must be even.'); + + $errors = Validator::make(['n' => 3], ['n' => 'even'])->errors(); + $this->assertSame('The n must be even.', $errors['n'][0]); + $this->assertTrue(Validator::make(['n' => 4], ['n' => 'even'])->passes()); + } + + public function test_extend_with_class_ruleset_ci_style(): void + { + Validator::extendWith(new class { + public function slug(mixed $v, ?string $p, array $d): bool + { + return is_string($v) && preg_match('/^[a-z0-9-]+$/', $v) === 1; + } + + /** @return array */ + public function messages(): array + { + return ['slug' => 'The :field must be kebab-case.']; + } + }); + + $errors = Validator::make(['s' => 'Bad Slug'], ['s' => 'slug'])->errors(); + $this->assertSame('The s must be kebab-case.', $errors['s'][0]); + } + + public function test_named_rule_group_ci_style(): void + { + Validator::defineGroup('login', ['email' => 'required|email'], ['email.required' => 'Need email.']); + + $errors = Validator::group('login', [])->errors(); + $this->assertSame('Need email.', $errors['email'][0]); + } + + public function test_unknown_group_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + Validator::group('nope', []); + } + + public function test_common_rules_pack_registers_and_validates(): void + { + Validator::extendWith(\Plugins\Validation\Rules\CommonRules::class); + + $ok = Validator::make( + ['id' => '550e8400-e29b-41d4-a716-446655440000', 'qty' => '12', 'ip' => '192.168.0.1'], + ['id' => 'uuid', 'qty' => 'is_natural_no_zero', 'ip' => 'ipv4'], + ); + $this->assertTrue($ok->passes()); + + $bad = Validator::make( + ['id' => 'nope', 'qty' => '0', 'when' => '2020-13-40'], + ['id' => 'uuid', 'qty' => 'is_natural_no_zero', 'when' => 'date_format:Y-m-d'], + )->errors(); + $this->assertArrayHasKey('id', $bad); + $this->assertArrayHasKey('qty', $bad); + $this->assertArrayHasKey('when', $bad); + } + + public function test_financial_rules_pack(): void + { + Validator::extendWith(\Plugins\Validation\Rules\FinancialRules::class); + + $ok = Validator::make( + ['card' => '4111111111111111', 'iban' => 'GB82WEST12345698765432', 'cvv' => '123'], + ['card' => 'credit_card', 'iban' => 'iban', 'cvv' => 'cvv'], + ); + $this->assertTrue($ok->passes()); + + $bad = Validator::make( + ['card' => '4111111111111112', 'iban' => 'GB00WEST12345698765432'], + ['card' => 'credit_card', 'iban' => 'iban'], + )->errors(); + $this->assertArrayHasKey('card', $bad); + $this->assertArrayHasKey('iban', $bad); + } } From f5e54b893fbeb2ef3dda58383dda9ce5570401df Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 14:33:47 +0300 Subject: [PATCH 024/140] chore(githooks): strip AI-authored trailers in commit-msg hook --- .githooks/commit-msg | 3 +++ 1 file changed, 3 insertions(+) create mode 100755 .githooks/commit-msg diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..c845388 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,3 @@ +#!/bin/sh +# Strip AI-authored trailers (Co-Authored-By: Claude / Generated with Claude Code) +grep -viE 'Co-authored-by:.*Claude|Generated with .*Claude' "$1" > "$1.tmp" && mv "$1.tmp" "$1" From d7c4044b39356f45fb500049cf4359b8f7e00e57 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 14:50:23 +0300 Subject: [PATCH 025/140] docs: rewrite README as a full framework guide (concepts, lifecycle, usage) --- README.md | 471 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 426 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 89762df..db18f40 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,103 @@ # AlfacodeTeam PhpServicePlatform -A modular **PHP 8.4+** backend framework built on the **Gated Demand Architecture -(GDA)** pattern — security runs before any module loads, and only the modules a -request actually needs are wired in. The kernel is codenamed **Sentinel**. +> A modular **PHP 8.4+** service framework built on the **Gated Demand Architecture (GDA)**. +> Security runs *before* any module loads, and only the modules a request actually needs are +> ever wired in. The kernel is codenamed **Sentinel**. -It ships as a **native cross-platform CLI** (`hkm`) built with Zig, so end users -install and upgrade it like a Go/Rust binary — no Composer required to get started. +[![PHP](https://img.shields.io/badge/PHP-8.4%2B-777bb4)](https://www.php.net/) +[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) +![Runtime](https://img.shields.io/badge/runtime-FPM%20%7C%20OpenSwoole-orange) + +It ships as a **native cross-platform CLI** (`hkm`) built with Zig, so you install and +upgrade it like a Go/Rust binary — no Composer needed to get started. --- -## Install (Linux / macOS / Windows) +## Table of contents + +1. [Why GDA?](#why-gda) +2. [Install](#install) +3. [The `hkm` CLI](#the-hkm-cli) +4. [Your first project](#your-first-project) +5. [Core concepts](#core-concepts) +6. [The request lifecycle](#the-request-lifecycle) +7. [Building a feature — end to end](#building-a-feature--end-to-end) +8. [The five access rules](#the-five-access-rules) +9. [Batteries included (plugins)](#batteries-included-plugins) +10. [Development from source](#development-from-source) +11. [Security defaults](#security-defaults) +12. [License](#license) + +--- + +## Why GDA? + +Most frameworks boot everything, then decide what to do. GDA inverts that: + +| Principle | What it means in practice | +|---|---| +| **Security before everything** | A `SecurityGateway` runs before any module loads. A denied request costs *zero* module wiring. | +| **Load only what is needed** | Only the modules required for *this* route are registered — resolved from a dependency graph per request. | +| **One module, one domain** | Every module owns exactly one bounded business domain. No exceptions. | +| **Isolation by default** | Modules cannot touch each other's internals — request-scoped containers enforce this at **runtime**. | +| **Infrastructure independence** | The kernel defines *port* interfaces; the project supplies implementations (MySQL, Redis, S3, …). | +| **Explicit over implicit** | Everything is declared in `module.json`. Nothing is auto-discovered at runtime. | + +The result: predictable performance (you pay only for what a route uses), strong domain +boundaries that hold at runtime, and infrastructure you can swap without touching business +code. -Download the latest release from -[Releases](https://github.com/AlfaCode-Team/php-service-platform/releases/latest): +> **This is not Laravel, Symfony, or Slim.** It borrows none of their conventions. If you're +> coming from those, unlearn the globals and facades — everything here is explicit and injected. -**Linux (Debian/Ubuntu/Kali):** +### The three worlds + +```text +┌─────────────────────────────────────────────────┐ +│ PROJECT LAYER (wiring only — no business logic)│ +│ ┌─────────────────────────────────────────────┐ │ +│ │ MODULE / PLUGIN LAYER (bounded domains) │ │ +│ │ ┌───────────────────────────────────────┐ │ │ +│ │ │ KERNEL (Sentinel) │ │ │ +│ │ │ boot · security · loading · DI · │ │ │ +│ │ │ pipelines · events · ports │ │ │ +│ │ └───────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +- **Kernel** — knows nothing about your domains. Changes rarely. +- **Module/Plugin** — knows nothing about the project. Wires to the kernel through contracts. +- **Project** — knows everything, contains no business logic. Pure wiring. + +--- + +## Install + +Download the latest build from +[Releases](https://github.com/AlfaCode-Team/php-service-platform/releases/latest). + +**Linux (Debian / Ubuntu / Kali)** ```bash sudo apt install ./hkm-kernel__amd64.deb hkm doctor # verify PHP + extensions ``` -**macOS:** extract `hkm-kernel--macos-universal.tar.gz`, then run +**macOS** — extract `hkm-kernel--macos-universal.tar.gz`, then run `HKM.app/Contents/Resources/opt/hkm-kernel/install.sh`. -**Windows:** extract `hkm-kernel--windows-x86_64.zip`, run -`hkm-kernel\install.bat`, add the folder to `PATH`. +**Windows** — extract `hkm-kernel--windows-x86_64.zip`, run +`hkm-kernel\install.bat`, and add the folder to `PATH`. -The launcher **self-locates** the kernel — no environment variables required on a -standard install. Dependencies are resolved with Composer on the target at -install time (the runtime matches your exact PHP). +The launcher **self-locates** the kernel — no environment variables required on a standard +install. Runtime PHP dependencies are resolved with Composer on the target at install time, +so they match your exact PHP. ### Requirements (verified by `hkm doctor`) + - PHP **≥ 8.4.1** - Extensions: `json, mbstring, ctype, tokenizer, filter, pdo, openssl, curl, fileinfo` -- At least one PDO driver (`mysql` / `pgsql` / `sqlite` / `sqlsrv`) +- At least one PDO driver: `mysql` · `pgsql` · `sqlite` · `sqlsrv` - Optional: `redis`, `swoole`/`openswoole`, `gd`, `intl` --- @@ -51,70 +115,387 @@ install time (the runtime matches your exact PHP). | `hkm ui [sync\|list\|link\|clean]` | Federate enabled plugins' UIs into the frontend | | `hkm doctor` | Diagnose PHP, extensions, and the resolved kernel path | | `hkm-config` | Set up / repair the full environment (kernel + userdata) | -| `hkm upgrade [--check]` | Check for and install a newer release automatically | +| `hkm upgrade [--check]` | Check for and install a newer release | | `hkm version` / `--version` / `-v` | Show the Sentinel banner + version | -| `hkm --dev` | Run any command against the **development** kernel checkout instead of the installed one | +| `hkm --dev` | Run any command against the **development** kernel checkout | ### Environment (all auto-detected — override only for non-standard layouts) + | Variable | Meaning | |---|---| -| `HKM_KERNEL_HOME` | Kernel root (holds `composer.json`, `vendor/`, `projects/`, `templates/`) | -| `HKM_DEV_HOME` | Development kernel checkout used by `--dev` (set once: `hkm-config set-dev-home `) | -| `HKM_USERDATA_DIR` | Persistent registry dir (`projects.json` + `platform.json`) that **survives updates** | +| `HKM_KERNEL_HOME` | Kernel root (`composer.json`, `vendor/`, `projects/`, `templates/`) | +| `HKM_DEV_HOME` | Development kernel checkout used by `--dev` (`hkm-config set-dev-home `) | +| `HKM_USERDATA_DIR` | Persistent registry (`projects.json` + `platform.json`) that **survives updates** | | `HKM_PHP_BIN` | Override the `php` binary | | `HKM_CLI_PATH` / `HKM_GLOBAL_AUTOLOAD` | Override the PHP CLI script / kernel autoload | Run `hkm-config` once and it pins `HKM_KERNEL_HOME` and provisions a persistent -`HKM_USERDATA_DIR` (migrating any existing registry) in -`~/.config/hkm/config.env`. +`HKM_USERDATA_DIR` (migrating any existing registry) into `~/.config/hkm/config.env`. + +--- + +## Your first project + +```bash +# 1. Scaffold — creates a hardened project skeleton +hkm new ~/apps/shop --project=shop + +# 2. Verify the environment +hkm doctor + +# 3. Run it locally +hkm run shop +# → serving http://127.0.0.1:8000 (docroot pinned to app/public) + +# 4. Console + queue worker for the same project +hkm cli shop migrate # run migrations (LetMigrate) +hkm worker shop # drain the job queue +``` + +A project is **wiring only**. Its bootstrap composes the kernel from a shared base and +declares which plugins it activates: + +```php +// projects/shop/bootstrap/app.php +/** @var Kernel $builder */ +$builder = require __DIR__ . '/../../../app/bootstrap/base.php'; + +return $builder + ->withProjectPath(dirname(__DIR__)) + ->withModules([ + Plugins\Auth\Provider::class, + Plugins\User\Provider::class, + Plugins\Task\Provider::class, + ]) + ->build(); +``` + +Incoming requests are mapped to a project by **host** (`app.example.com` → the `shop` +project) via `DomainResolver`, falling back to the `HKM_PROJECT` env var, then `admin`. + +--- + +## Core concepts + +### Modules & plugins + +A **plugin** (local business module) lives under `plugins//`, uses the `Plugins\\` +namespace, and follows a strict GDA folder layout: + +``` +plugins/Invoice/ +├── module.json ← single source of truth +├── API/Contracts/InvoiceServiceContract.php ← the ONLY thing other modules may import +├── Domain/ ← entities, value objects, domain events (zero external imports) +├── Application/Services/InvoiceService.php ← transaction + event orchestration +├── Infrastructure/ +│ ├── Persistence/InvoiceRepository.php ← DatabasePort only +│ ├── Gateways/StripeGateway.php ← vendor SDK only +│ └── Http/Controllers/InvoiceController.php ← ≤3-line actions +└── Provider.php ← implements ModuleContract +``` + +### `module.json` — the single source of truth + +Routes, config, dependencies, and emitted events are **declared**, never discovered: + +```json +{ + "name": "invoice", + "solves": "invoice.generation", + "type": "module", + "requires": ["database.query"], + "exposes": ["InvoiceServiceContract"], + "routes": [ + { "method": "GET", "path": "/api/invoices", "handler": "InvoiceController@index" }, + { "method": "POST", "path": "/api/invoices", "handler": "InvoiceController@create", "filters": ["auth", "throttle:60,1"] }, + { "method": "GET", "path": "/api/invoices/{id}", "handler": "InvoiceController@show" } + ], + "emits": ["invoice.created", "invoice.paid"], + "config": ["INVOICE_CURRENCY", { "key": "INVOICE_TAX_RATE", "type": "float", "required": false }] +} +``` + +If a module reads an env var that isn't in `config[]`, **boot fails** — no silent misconfig. + +### Ports (infrastructure independence) + +The kernel defines interfaces; the project binds implementations once, at the app-lifetime +container: + +```php +->withPorts([ + DatabasePort::class => new MySQLAdapter(config('database')), + CachePort::class => new RedisAdapter(config('cache')), + QueuePort::class => new RedisQueueAdapter(config('jobs')), + MailPort::class => new SmtpMailAdapter(config('mail')), + StoragePort::class => new S3StorageAdapter(config('storage')), +]) +``` + +Swap MySQL for Postgres, or SMTP for SES, without touching a single line of domain code. + +--- + +## The request lifecycle + +```text +Request + │ + ▼ SecurityGateway (runs BEFORE any module loads) + │ Firewall → RateLimiter → CSRF → [your Auth layer] ── deny = zero module cost + ▼ +HTTP pipeline + 1. CorrelationIdStage propagate X-Correlation-ID + 2. SecurityStage run the gateway, attach Identity + 3. ResolveStage route-manifest lookup → service name + 4. LoadStage build dependency graph → wire ONLY those modules + ↳ RouteFilterStage run the route's declared filters[] (auth, throttle, …) + 5. ExecuteStage contract → DTO → controller → Response + 6. ErrorStage (wraps all) classify → notify (Slack/Mail/DB/File) → HTTP response +``` + +Every stage is `handle(Request $request, callable $next): Response`. Modules add +cross-cutting behaviour by registering hooks in `Provider::boot()`, or opt individual routes +into named **filters** via `module.json`. + +--- + +## Building a feature — end to end + +A complete vertical slice. Each layer has one job and may only talk to the layer below it. + +### 1. Domain — pure, zero external imports + +```php +// Domain/ValueObjects/Money.php +final readonly class Money +{ + private function __construct(private int $amount, private string $currency) { // integer cents — NEVER float + if ($this->amount < 0) throw new \DomainException('Money cannot be negative'); + } + public static function of(int|float $amount, string $currency): self { + return new self((int) round($amount * 100), strtoupper($currency)); + } + public function add(self $o): self { + if ($this->currency !== $o->currency) throw new \DomainException('Currency mismatch'); + return new self($this->amount + $o->amount, $this->currency); // operations return NEW instances + } + public function amount(): int { return $this->amount; } +} +``` + +### 2. Service — transaction + event orchestration (the mandatory shape) + +```php +final class InvoiceService implements InvoiceServiceContract +{ + public function __construct( + private readonly InvoiceRepository $repository, + private readonly TransactionManager $transaction, + private readonly DomainEventCollector $collector, + private readonly EventBus $eventBus, + private readonly Identity $identity, // from the SecurityGateway + ) {} + + public function create(CreateInvoiceDTO $dto): InvoiceResponseDTO + { + // authorization first + if (!$this->identity->hasPermission('invoice:create')) { + throw new ServiceException('invoice.unauthorized', layer: 'service.invoice'); + } + + $this->collector->beginCollection(); + $this->transaction->begin(); + try { + $invoice = Invoice::create(ClientId::from($dto->clientId), Money::of($dto->amount, 'USD')); + foreach ($invoice->releaseEvents() as $e) $this->collector->collect($e); + $this->repository->save($invoice); + $this->transaction->commit(); + } catch (\Throwable $e) { + $this->transaction->rollback(); + $this->collector->discard(); // ALWAYS — no phantom events on rollback + throw new ServiceException('invoice.create.failed', layer: 'service.invoice', previous: $e); + } + + // integration events dispatch ONLY after a successful commit — never inside try{} + $this->eventBus->dispatch(new InvoiceCreatedIntegrationEvent($invoice->id()->value(), $dto->amount)); + + return InvoiceResponseDTO::from($invoice); + } +} +``` + +### 3. Repository — `DatabasePort` only, translate every `\PDOException` + +```php +final class InvoiceRepository +{ + public function __construct(private readonly DatabasePort $db, private readonly Identity $identity) {} + + public function find(string $id): Invoice + { + try { + $row = $this->db->queryOne( + 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t AND deleted_at IS NULL', + ['id' => $id, 't' => $this->identity->tenantId], // ALWAYS tenant-scoped + ); + } catch (\PDOException $e) { + throw new RepositoryException("find invoice [$id]", layer: 'repository.invoice', previous: $e); + } + return $row ? InvoiceHydrator::hydrate($row) : throw new RepositoryException("Invoice [$id] not found"); + } +} +``` + +### 4. Controller — ≤3 lines: DTO → service → Response + +```php +final class InvoiceController +{ + public function __construct(private readonly InvoiceServiceContract $service) {} // contract only + + public function create(Request $request): Response + { + $dto = CreateInvoiceDTO::fromRequest($request); // validation happens here + return Response::json($this->service->create($dto)->toArray(), 201); + } +} +``` + +### 5. Provider — wire it together + +```php +class Provider implements ModuleContract +{ + public function solves(): string { return 'invoice.generation'; } + public function requires(): array { return [DatabasePort::class]; } + public function exposes(): array { return [InvoiceServiceContract::class]; } + + public function register(ModuleContainer $c): void + { + $c->bindInternal(InvoiceRepository::class, fn($c) => + new InvoiceRepository($c->make(DatabasePort::class), $c->make(Identity::class))); + + $c->bind(InvoiceServiceContract::class, fn($c) => new InvoiceService( + $c->make(InvoiceRepository::class), $c->make(TransactionManager::class), + $c->make(DomainEventCollector::class), $c->make(EventBus::class), $c->make(Identity::class), + )); + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + // $events->subscribe('payment.succeeded', MarkInvoicePaidListener::class); + } +} +``` + +Add `Plugins\Invoice\Provider::class` to a project's `withModules([...])` and the routes, +config validation, and DI are live. + +### Testing — always fakes, never real infrastructure + +```php +$sut = new InvoiceService( + new InMemoryInvoiceRepository(), new FakeTransactionManager(), + new DomainEventCollector(), $bus = new FakeIntegrationEventBus(), Identity::asUser('u-1', 'tenant-a'), +); +$result = $sut->create($validDto); + +$bus->assertDispatched(InvoiceCreatedIntegrationEvent::class, times: 1); +$this->assertNotNull($result->invoiceId); +``` --- -## Development (from source) +## The five access rules + +These are **enforced at runtime** by `ModuleContainer::bindInternal()` — a violation throws +`ScopeViolationException`, not a lint warning. + +```text +Controller → Service (published contract interface ONLY) +Service → Repository AND Gateway (the ONLY layer that may call both) +Repository → DatabasePort ONLY (no HTTP, no vendor SDK) +Gateway → Vendor SDK ONLY (no DB, no services) +Domain → NOTHING EXTERNAL (zero imports outside Domain/) +``` + +**Never** (a partial list the framework actively rejects): +- Routes defined in PHP — only in `module.json` / `proj.json`. +- `float` for money — use a `Money` value object with integer cents. +- Vendor exceptions (`\PDOException`, Stripe, …) escaping their layer — translate them. +- Integration events dispatched inside a `try{}` — only after commit. +- Another module's internal class imported — use its published contract. +- `getenv()` for a `.env` value — use the `env()` helper. +- Business logic in a controller — max 3 lines. + +--- + +## Batteries included (plugins) + +Drop-in modules under `plugins/`, activated per project: + +| Plugin | Domain | What you get | +|---|---|---| +| **Auth** | `auth.identity` | JWT / PAT / session issuance + verification, refresh-token rotation, guards | +| **OAuth2** | `oauth.server` | Native OAuth 2.1 + OIDC server (auth code + PKCE, device code, JWKS, introspection) | +| **User** | `user.management` | Central identity store, email verification, transactional outbox, audit log | +| **Tenancy** | `tenancy.routing` | Multi-tenant DB routing, memberships, invitations, per-tenant isolation | +| **Validation** | `validation.rules` | Request validation engine + `AbstractDto` (`rules()`), ~45 built-in rules | +| **Mail** | `mail.delivery` | Native dependency-free mailer — SMTP/Sendmail/`mail()`, DKIM, attachments | +| **Storage** | `storage.local` | `StoragePort` over local disk **or** S3 (Flysystem), signed temp URLs | +| **Session / Cookie** | `session.management` / `http.cookies` | Encrypted sessions, flash, CSRF; queued encrypted cookies | +| **HttpClient** | `http.client` | `HttpClientPort` (cURL) with idempotent-safe retries + coroutine backoff | +| **View / ViteManifest / Pageflow** | frontend | PHP templating, Vite asset resolution, Inertia-style SPA bridge | +| **SecurityFilters** | `http.security_filters` | CORS + secure headers; route-filter aliases `auth`, `throttle`, `hmac`, `shield` | +| **I18n** | `i18n.translation` | File-based translator, pluralization, `Accept-Language` negotiation | + +Each plugin ships its own `README.md` — e.g. [Auth](plugins/Auth/README.md), +[Tenancy](plugins/Tenancy/README.md), [User](plugins/User/README.md), +[OAuth2](plugins/OAuth2/README.md). + +--- + +## Development from source ```bash git clone --recurse-submodules git@github.com:AlfaCode-Team/php-service-platform.git cd php-service-platform composer install -vendor/bin/phpunit # run the test suite +vendor/bin/phpunit # run the test suite # Build the native launcher (needs Zig — see tools/.zig-version): cd tools && zig build --release=small # → ../bin/hkm + ../bin/hkm-config ``` ### Building release bundles + ```bash VERSION=1.2.3 ./tools/bundle.sh all # .deb + macOS .app + Windows .zip → dist/ # MODULES=git ./tools/bundle.sh linux # fetch path-repo modules from pinned commits ``` -Releases are cut by pushing a `v*` tag — CI runs the test suite first, then builds -all three OS bundles on Linux and publishes them automatically. - ---- - -## Architecture at a glance -- **Kernel (Sentinel)** — boot pipeline, SecurityGateway, on-demand loading, - scoped DI containers, HTTP/CLI/Worker pipelines, ports. -- **Plugins** (`plugins/`, `Plugins\` namespace) — bounded business/infrastructure - modules (Auth, OAuth2, Tenancy, User, Storage, Session, Cookie, View, …). -- **Projects** (`projects/`) — per-project wiring; the runtime resolves an - incoming host to a project via `DomainResolver`. +Releases are cut by pushing a `v*` tag — CI runs the test suite first, then builds all three +OS bundles and publishes them automatically. -See the per-plugin `README.md` files (e.g. [Auth](plugins/Auth/README.md), -[Tenancy](plugins/Tenancy/README.md), [User](plugins/User/README.md)) and the +For deep dives, see the layer guides in [`docs/ai-context/`](docs/) and the [CHANGELOG](CHANGELOG.md). --- ## Security defaults -Scaffolded projects are hardened by default: `.env` is `chmod 600`, debug output -is force-disabled when `APP_ENV=production`, and every new project ships web-server -configs (`app/public/.htaccess`, `app/apache.conf.example`, `app/nginx.conf.example`) -that pin the docroot to `app/public`, deny dotfiles, and add baseline security -headers. Keep secrets (`APP_KEY`, JWT signing keys, DB credentials) out of the CLI -config and, in production, behind a `SECRETS_PROVIDER`. +Scaffolded projects are hardened out of the box: + +- `.env` is `chmod 600`; debug output is force-disabled when `APP_ENV=production`. +- Every project ships web-server configs (`app/public/.htaccess`, + `app/apache.conf.example`, `app/nginx.conf.example`) pinning the docroot to `app/public`, + denying dotfiles, and adding baseline security headers. +- The `SecurityGateway` (firewall → rate limiter → CSRF → your auth layer) runs before any + module — denied requests never touch business code. +- Keep secrets (`APP_KEY`, JWT signing keys, DB credentials) out of the CLI config and, + in production, behind a `SECRETS_PROVIDER`. --- From eed64733d96a527780b5e73bf5bbc8a1bba3cfe4 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 14:53:02 +0300 Subject: [PATCH 026/140] docs: rewrite README as a full framework guide (concepts, lifecycle, usage) --- README.md | 524 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 493 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 67b906c..db18f40 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,504 @@ -# php-service-platform +# AlfacodeTeam PhpServicePlatform -Documentation has been organized under [docs/README.md](docs/README.md). +> A modular **PHP 8.4+** service framework built on the **Gated Demand Architecture (GDA)**. +> Security runs *before* any module loads, and only the modules a request actually needs are +> ever wired in. The kernel is codenamed **Sentinel**. -Native global installation (Linux/macOS/Windows): +[![PHP](https://img.shields.io/badge/PHP-8.4%2B-777bb4)](https://www.php.net/) +[![License](https://img.shields.io/badge/License-MIT-green)](LICENSE) +![Runtime](https://img.shields.io/badge/runtime-FPM%20%7C%20OpenSwoole-orange) -1. Create a release tag (CI builds installers): - - `git tag v1.0.0 && git push origin v1.0.0` -2. Install from release artifacts: - - Linux: `psp-kernel__amd64.deb` - - Windows: `psp-kernel--windows-x86_64.zip` - - macOS: `psp-kernel--macos-universal.tar.gz` -3. Scaffold and run a project anywhere: - - `psp new /absolute/path/to/my-project --project=admin` - - `php /absolute/path/to/my-project/app/cli/run.php list` +It ships as a **native cross-platform CLI** (`hkm`) built with Zig, so you install and +upgrade it like a Go/Rust binary — no Composer needed to get started. -Notes: +--- -- The native launcher reads kernel location from `PSP_KERNEL_HOME` or `PSP_CLI_PATH`. -- Optional override for generated project autoload: `PSP_GLOBAL_AUTOLOAD=/path/to/vendor/autoload.php`. -- Full packaging and install instructions: [packaging/README.md](packaging/README.md). +## Table of contents -Composer-based global install remains supported for development: +1. [Why GDA?](#why-gda) +2. [Install](#install) +3. [The `hkm` CLI](#the-hkm-cli) +4. [Your first project](#your-first-project) +5. [Core concepts](#core-concepts) +6. [The request lifecycle](#the-request-lifecycle) +7. [Building a feature — end to end](#building-a-feature--end-to-end) +8. [The five access rules](#the-five-access-rules) +9. [Batteries included (plugins)](#batteries-included-plugins) +10. [Development from source](#development-from-source) +11. [Security defaults](#security-defaults) +12. [License](#license) -- `composer global require alfacode-team/php-service-platform` +--- -Native system installers (no Composer required for end users): +## Why GDA? -- Debian/Kali apt package scaffolding: `packaging/apt/` -- Windows `.exe` bundle scaffolding: `packaging/windows/` -- macOS `.app` bundle scaffolding: `packaging/macos/` -- Zig launcher/config utility: `tools/psp-launcher-zig/` +Most frameworks boot everything, then decide what to do. GDA inverts that: -Key locations: +| Principle | What it means in practice | +|---|---| +| **Security before everything** | A `SecurityGateway` runs before any module loads. A denied request costs *zero* module wiring. | +| **Load only what is needed** | Only the modules required for *this* route are registered — resolved from a dependency graph per request. | +| **One module, one domain** | Every module owns exactly one bounded business domain. No exceptions. | +| **Isolation by default** | Modules cannot touch each other's internals — request-scoped containers enforce this at **runtime**. | +| **Infrastructure independence** | The kernel defines *port* interfaces; the project supplies implementations (MySQL, Redis, S3, …). | +| **Explicit over implicit** | Everything is declared in `module.json`. Nothing is auto-discovered at runtime. | -- Commands reports: [docs/reports/commands](docs/reports/commands) -- Database reports: [docs/reports/database](docs/reports/database) -- Enterprise reports: [docs/reports/enterprise](docs/reports/enterprise) -- Infrastructure reports: [docs/reports/infrastructure](docs/reports/infrastructure) -- Migrations reports: [docs/reports/migrations](docs/reports/migrations) -- Deployment guides: [docs/guides](docs/guides) -- AI context: [docs/ai-context](docs/ai-context) +The result: predictable performance (you pay only for what a route uses), strong domain +boundaries that hold at runtime, and infrastructure you can swap without touching business +code. + +> **This is not Laravel, Symfony, or Slim.** It borrows none of their conventions. If you're +> coming from those, unlearn the globals and facades — everything here is explicit and injected. + +### The three worlds + +```text +┌─────────────────────────────────────────────────┐ +│ PROJECT LAYER (wiring only — no business logic)│ +│ ┌─────────────────────────────────────────────┐ │ +│ │ MODULE / PLUGIN LAYER (bounded domains) │ │ +│ │ ┌───────────────────────────────────────┐ │ │ +│ │ │ KERNEL (Sentinel) │ │ │ +│ │ │ boot · security · loading · DI · │ │ │ +│ │ │ pipelines · events · ports │ │ │ +│ │ └───────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +- **Kernel** — knows nothing about your domains. Changes rarely. +- **Module/Plugin** — knows nothing about the project. Wires to the kernel through contracts. +- **Project** — knows everything, contains no business logic. Pure wiring. + +--- + +## Install + +Download the latest build from +[Releases](https://github.com/AlfaCode-Team/php-service-platform/releases/latest). + +**Linux (Debian / Ubuntu / Kali)** +```bash +sudo apt install ./hkm-kernel__amd64.deb +hkm doctor # verify PHP + extensions +``` + +**macOS** — extract `hkm-kernel--macos-universal.tar.gz`, then run +`HKM.app/Contents/Resources/opt/hkm-kernel/install.sh`. + +**Windows** — extract `hkm-kernel--windows-x86_64.zip`, run +`hkm-kernel\install.bat`, and add the folder to `PATH`. + +The launcher **self-locates** the kernel — no environment variables required on a standard +install. Runtime PHP dependencies are resolved with Composer on the target at install time, +so they match your exact PHP. + +### Requirements (verified by `hkm doctor`) + +- PHP **≥ 8.4.1** +- Extensions: `json, mbstring, ctype, tokenizer, filter, pdo, openssl, curl, fileinfo` +- At least one PDO driver: `mysql` · `pgsql` · `sqlite` · `sqlsrv` +- Optional: `redis`, `swoole`/`openswoole`, `gd`, `intl` + +--- + +## The `hkm` CLI + +| Command | Purpose | +|---|---| +| `hkm new [--project=]` | Scaffold a new project (secure defaults: `.env` chmod 600, Apache **+** nginx configs) | +| `hkm run [path\|name]` | Run a project locally (PHP dev server) | +| `hkm cli [command]` | Run a project's console interactively | +| `hkm worker [args]` | Run a project's queue worker | +| `hkm list` | List registered projects | +| `hkm plugins [path\|name]` | Analyse a project's enabled plugins/modules | +| `hkm ui [sync\|list\|link\|clean]` | Federate enabled plugins' UIs into the frontend | +| `hkm doctor` | Diagnose PHP, extensions, and the resolved kernel path | +| `hkm-config` | Set up / repair the full environment (kernel + userdata) | +| `hkm upgrade [--check]` | Check for and install a newer release | +| `hkm version` / `--version` / `-v` | Show the Sentinel banner + version | +| `hkm --dev` | Run any command against the **development** kernel checkout | + +### Environment (all auto-detected — override only for non-standard layouts) + +| Variable | Meaning | +|---|---| +| `HKM_KERNEL_HOME` | Kernel root (`composer.json`, `vendor/`, `projects/`, `templates/`) | +| `HKM_DEV_HOME` | Development kernel checkout used by `--dev` (`hkm-config set-dev-home `) | +| `HKM_USERDATA_DIR` | Persistent registry (`projects.json` + `platform.json`) that **survives updates** | +| `HKM_PHP_BIN` | Override the `php` binary | +| `HKM_CLI_PATH` / `HKM_GLOBAL_AUTOLOAD` | Override the PHP CLI script / kernel autoload | + +Run `hkm-config` once and it pins `HKM_KERNEL_HOME` and provisions a persistent +`HKM_USERDATA_DIR` (migrating any existing registry) into `~/.config/hkm/config.env`. + +--- + +## Your first project + +```bash +# 1. Scaffold — creates a hardened project skeleton +hkm new ~/apps/shop --project=shop + +# 2. Verify the environment +hkm doctor + +# 3. Run it locally +hkm run shop +# → serving http://127.0.0.1:8000 (docroot pinned to app/public) + +# 4. Console + queue worker for the same project +hkm cli shop migrate # run migrations (LetMigrate) +hkm worker shop # drain the job queue +``` + +A project is **wiring only**. Its bootstrap composes the kernel from a shared base and +declares which plugins it activates: + +```php +// projects/shop/bootstrap/app.php +/** @var Kernel $builder */ +$builder = require __DIR__ . '/../../../app/bootstrap/base.php'; + +return $builder + ->withProjectPath(dirname(__DIR__)) + ->withModules([ + Plugins\Auth\Provider::class, + Plugins\User\Provider::class, + Plugins\Task\Provider::class, + ]) + ->build(); +``` + +Incoming requests are mapped to a project by **host** (`app.example.com` → the `shop` +project) via `DomainResolver`, falling back to the `HKM_PROJECT` env var, then `admin`. + +--- + +## Core concepts + +### Modules & plugins + +A **plugin** (local business module) lives under `plugins//`, uses the `Plugins\\` +namespace, and follows a strict GDA folder layout: + +``` +plugins/Invoice/ +├── module.json ← single source of truth +├── API/Contracts/InvoiceServiceContract.php ← the ONLY thing other modules may import +├── Domain/ ← entities, value objects, domain events (zero external imports) +├── Application/Services/InvoiceService.php ← transaction + event orchestration +├── Infrastructure/ +│ ├── Persistence/InvoiceRepository.php ← DatabasePort only +│ ├── Gateways/StripeGateway.php ← vendor SDK only +│ └── Http/Controllers/InvoiceController.php ← ≤3-line actions +└── Provider.php ← implements ModuleContract +``` + +### `module.json` — the single source of truth + +Routes, config, dependencies, and emitted events are **declared**, never discovered: + +```json +{ + "name": "invoice", + "solves": "invoice.generation", + "type": "module", + "requires": ["database.query"], + "exposes": ["InvoiceServiceContract"], + "routes": [ + { "method": "GET", "path": "/api/invoices", "handler": "InvoiceController@index" }, + { "method": "POST", "path": "/api/invoices", "handler": "InvoiceController@create", "filters": ["auth", "throttle:60,1"] }, + { "method": "GET", "path": "/api/invoices/{id}", "handler": "InvoiceController@show" } + ], + "emits": ["invoice.created", "invoice.paid"], + "config": ["INVOICE_CURRENCY", { "key": "INVOICE_TAX_RATE", "type": "float", "required": false }] +} +``` + +If a module reads an env var that isn't in `config[]`, **boot fails** — no silent misconfig. + +### Ports (infrastructure independence) + +The kernel defines interfaces; the project binds implementations once, at the app-lifetime +container: + +```php +->withPorts([ + DatabasePort::class => new MySQLAdapter(config('database')), + CachePort::class => new RedisAdapter(config('cache')), + QueuePort::class => new RedisQueueAdapter(config('jobs')), + MailPort::class => new SmtpMailAdapter(config('mail')), + StoragePort::class => new S3StorageAdapter(config('storage')), +]) +``` + +Swap MySQL for Postgres, or SMTP for SES, without touching a single line of domain code. + +--- + +## The request lifecycle + +```text +Request + │ + ▼ SecurityGateway (runs BEFORE any module loads) + │ Firewall → RateLimiter → CSRF → [your Auth layer] ── deny = zero module cost + ▼ +HTTP pipeline + 1. CorrelationIdStage propagate X-Correlation-ID + 2. SecurityStage run the gateway, attach Identity + 3. ResolveStage route-manifest lookup → service name + 4. LoadStage build dependency graph → wire ONLY those modules + ↳ RouteFilterStage run the route's declared filters[] (auth, throttle, …) + 5. ExecuteStage contract → DTO → controller → Response + 6. ErrorStage (wraps all) classify → notify (Slack/Mail/DB/File) → HTTP response +``` + +Every stage is `handle(Request $request, callable $next): Response`. Modules add +cross-cutting behaviour by registering hooks in `Provider::boot()`, or opt individual routes +into named **filters** via `module.json`. + +--- + +## Building a feature — end to end + +A complete vertical slice. Each layer has one job and may only talk to the layer below it. + +### 1. Domain — pure, zero external imports + +```php +// Domain/ValueObjects/Money.php +final readonly class Money +{ + private function __construct(private int $amount, private string $currency) { // integer cents — NEVER float + if ($this->amount < 0) throw new \DomainException('Money cannot be negative'); + } + public static function of(int|float $amount, string $currency): self { + return new self((int) round($amount * 100), strtoupper($currency)); + } + public function add(self $o): self { + if ($this->currency !== $o->currency) throw new \DomainException('Currency mismatch'); + return new self($this->amount + $o->amount, $this->currency); // operations return NEW instances + } + public function amount(): int { return $this->amount; } +} +``` + +### 2. Service — transaction + event orchestration (the mandatory shape) + +```php +final class InvoiceService implements InvoiceServiceContract +{ + public function __construct( + private readonly InvoiceRepository $repository, + private readonly TransactionManager $transaction, + private readonly DomainEventCollector $collector, + private readonly EventBus $eventBus, + private readonly Identity $identity, // from the SecurityGateway + ) {} + + public function create(CreateInvoiceDTO $dto): InvoiceResponseDTO + { + // authorization first + if (!$this->identity->hasPermission('invoice:create')) { + throw new ServiceException('invoice.unauthorized', layer: 'service.invoice'); + } + + $this->collector->beginCollection(); + $this->transaction->begin(); + try { + $invoice = Invoice::create(ClientId::from($dto->clientId), Money::of($dto->amount, 'USD')); + foreach ($invoice->releaseEvents() as $e) $this->collector->collect($e); + $this->repository->save($invoice); + $this->transaction->commit(); + } catch (\Throwable $e) { + $this->transaction->rollback(); + $this->collector->discard(); // ALWAYS — no phantom events on rollback + throw new ServiceException('invoice.create.failed', layer: 'service.invoice', previous: $e); + } + + // integration events dispatch ONLY after a successful commit — never inside try{} + $this->eventBus->dispatch(new InvoiceCreatedIntegrationEvent($invoice->id()->value(), $dto->amount)); + + return InvoiceResponseDTO::from($invoice); + } +} +``` + +### 3. Repository — `DatabasePort` only, translate every `\PDOException` + +```php +final class InvoiceRepository +{ + public function __construct(private readonly DatabasePort $db, private readonly Identity $identity) {} + + public function find(string $id): Invoice + { + try { + $row = $this->db->queryOne( + 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t AND deleted_at IS NULL', + ['id' => $id, 't' => $this->identity->tenantId], // ALWAYS tenant-scoped + ); + } catch (\PDOException $e) { + throw new RepositoryException("find invoice [$id]", layer: 'repository.invoice', previous: $e); + } + return $row ? InvoiceHydrator::hydrate($row) : throw new RepositoryException("Invoice [$id] not found"); + } +} +``` + +### 4. Controller — ≤3 lines: DTO → service → Response + +```php +final class InvoiceController +{ + public function __construct(private readonly InvoiceServiceContract $service) {} // contract only + + public function create(Request $request): Response + { + $dto = CreateInvoiceDTO::fromRequest($request); // validation happens here + return Response::json($this->service->create($dto)->toArray(), 201); + } +} +``` + +### 5. Provider — wire it together + +```php +class Provider implements ModuleContract +{ + public function solves(): string { return 'invoice.generation'; } + public function requires(): array { return [DatabasePort::class]; } + public function exposes(): array { return [InvoiceServiceContract::class]; } + + public function register(ModuleContainer $c): void + { + $c->bindInternal(InvoiceRepository::class, fn($c) => + new InvoiceRepository($c->make(DatabasePort::class), $c->make(Identity::class))); + + $c->bind(InvoiceServiceContract::class, fn($c) => new InvoiceService( + $c->make(InvoiceRepository::class), $c->make(TransactionManager::class), + $c->make(DomainEventCollector::class), $c->make(EventBus::class), $c->make(Identity::class), + )); + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + // $events->subscribe('payment.succeeded', MarkInvoicePaidListener::class); + } +} +``` + +Add `Plugins\Invoice\Provider::class` to a project's `withModules([...])` and the routes, +config validation, and DI are live. + +### Testing — always fakes, never real infrastructure + +```php +$sut = new InvoiceService( + new InMemoryInvoiceRepository(), new FakeTransactionManager(), + new DomainEventCollector(), $bus = new FakeIntegrationEventBus(), Identity::asUser('u-1', 'tenant-a'), +); +$result = $sut->create($validDto); + +$bus->assertDispatched(InvoiceCreatedIntegrationEvent::class, times: 1); +$this->assertNotNull($result->invoiceId); +``` + +--- + +## The five access rules + +These are **enforced at runtime** by `ModuleContainer::bindInternal()` — a violation throws +`ScopeViolationException`, not a lint warning. + +```text +Controller → Service (published contract interface ONLY) +Service → Repository AND Gateway (the ONLY layer that may call both) +Repository → DatabasePort ONLY (no HTTP, no vendor SDK) +Gateway → Vendor SDK ONLY (no DB, no services) +Domain → NOTHING EXTERNAL (zero imports outside Domain/) +``` + +**Never** (a partial list the framework actively rejects): +- Routes defined in PHP — only in `module.json` / `proj.json`. +- `float` for money — use a `Money` value object with integer cents. +- Vendor exceptions (`\PDOException`, Stripe, …) escaping their layer — translate them. +- Integration events dispatched inside a `try{}` — only after commit. +- Another module's internal class imported — use its published contract. +- `getenv()` for a `.env` value — use the `env()` helper. +- Business logic in a controller — max 3 lines. + +--- + +## Batteries included (plugins) + +Drop-in modules under `plugins/`, activated per project: + +| Plugin | Domain | What you get | +|---|---|---| +| **Auth** | `auth.identity` | JWT / PAT / session issuance + verification, refresh-token rotation, guards | +| **OAuth2** | `oauth.server` | Native OAuth 2.1 + OIDC server (auth code + PKCE, device code, JWKS, introspection) | +| **User** | `user.management` | Central identity store, email verification, transactional outbox, audit log | +| **Tenancy** | `tenancy.routing` | Multi-tenant DB routing, memberships, invitations, per-tenant isolation | +| **Validation** | `validation.rules` | Request validation engine + `AbstractDto` (`rules()`), ~45 built-in rules | +| **Mail** | `mail.delivery` | Native dependency-free mailer — SMTP/Sendmail/`mail()`, DKIM, attachments | +| **Storage** | `storage.local` | `StoragePort` over local disk **or** S3 (Flysystem), signed temp URLs | +| **Session / Cookie** | `session.management` / `http.cookies` | Encrypted sessions, flash, CSRF; queued encrypted cookies | +| **HttpClient** | `http.client` | `HttpClientPort` (cURL) with idempotent-safe retries + coroutine backoff | +| **View / ViteManifest / Pageflow** | frontend | PHP templating, Vite asset resolution, Inertia-style SPA bridge | +| **SecurityFilters** | `http.security_filters` | CORS + secure headers; route-filter aliases `auth`, `throttle`, `hmac`, `shield` | +| **I18n** | `i18n.translation` | File-based translator, pluralization, `Accept-Language` negotiation | + +Each plugin ships its own `README.md` — e.g. [Auth](plugins/Auth/README.md), +[Tenancy](plugins/Tenancy/README.md), [User](plugins/User/README.md), +[OAuth2](plugins/OAuth2/README.md). + +--- + +## Development from source + +```bash +git clone --recurse-submodules git@github.com:AlfaCode-Team/php-service-platform.git +cd php-service-platform +composer install +vendor/bin/phpunit # run the test suite + +# Build the native launcher (needs Zig — see tools/.zig-version): +cd tools && zig build --release=small # → ../bin/hkm + ../bin/hkm-config +``` + +### Building release bundles + +```bash +VERSION=1.2.3 ./tools/bundle.sh all # .deb + macOS .app + Windows .zip → dist/ +# MODULES=git ./tools/bundle.sh linux # fetch path-repo modules from pinned commits +``` + +Releases are cut by pushing a `v*` tag — CI runs the test suite first, then builds all three +OS bundles and publishes them automatically. + +For deep dives, see the layer guides in [`docs/ai-context/`](docs/) and the +[CHANGELOG](CHANGELOG.md). + +--- + +## Security defaults + +Scaffolded projects are hardened out of the box: + +- `.env` is `chmod 600`; debug output is force-disabled when `APP_ENV=production`. +- Every project ships web-server configs (`app/public/.htaccess`, + `app/apache.conf.example`, `app/nginx.conf.example`) pinning the docroot to `app/public`, + denying dotfiles, and adding baseline security headers. +- The `SecurityGateway` (firewall → rate limiter → CSRF → your auth layer) runs before any + module — denied requests never touch business code. +- Keep secrets (`APP_KEY`, JWT signing keys, DB credentials) out of the CLI config and, + in production, behind a `SECRETS_PROVIDER`. + +--- + +## License + +MIT — see [LICENSE](LICENSE). From a7c468f9918cfbed4cd03391137d53f88bec799d Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 15:02:06 +0300 Subject: [PATCH 027/140] chore: stop tracking tools/zig-out build output (rebuilt by zig build / bundle.sh) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 067247c..2af2ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ # bin/psp (the PHP CLI) IS tracked; the arch-specific Zig binaries are not. /bin/hkm /bin/hkm-config +# zig build output — rebuilt by `zig build` / tools/bundle.sh; never shipped from git. +tools/zig-out/ # ── AI ASSISTANT / INTERNAL DOCS (kept local, NOT shipped to GitHub) ── From 6c4048aadaa1c2889f9d47bc44766f8b106ecccf Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Sat, 11 Jul 2026 17:39:39 +0300 Subject: [PATCH 028/140] fix(cli,tenancy): resolve tenant:migrate command collision + project-relative template path - CliPipeline::hasQueued() lets the generic migration factory yield to a plugin-claimed command name, so the kernel LetMigrate tenant:* commands no longer shadow the Tenancy plugin's registry-based tenant:migrate. - Tenancy tenant:migrate template path now resolves under the active project root (Paths::project); TENANCY_TEMPLATE_PATH honoured absolute or project-relative; plugin-dir fallback removed. Release v1.0.8. --- CHANGELOG.md | 24 +++++++++++++++++++ plugins/Commands/Provider.php | 9 +++++++ .../Cli/MigrateTenantsCommand.php | 14 +++++++++-- src/Kernel/Pipelines/Cli/CliPipeline.php | 22 +++++++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fd4d7..9b35442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.8] - 2026-07-11 + +### Fixed +- **`tenant:migrate` command collision.** The kernel's generic LetMigrate + `tenant:*` commands (registered via the `Commands` plugin's migration factory) + were overwriting the Tenancy plugin's registry-based `tenant:migrate` under the + CLI's last-wins registration, so the wrong command ran and demanded a + `tenants` resolver config the project does not use. The generic factory now + yields to any command a plugin already claimed via the new + `CliPipeline::hasQueued()` — so the Tenancy command wins when Tenancy is + enabled, and the kernel commands still register normally when it is not. + +### Changed +- **Tenancy `tenant:migrate` template path is now project-relative.** The default + template migrations path resolves under the active project root + (`projects//database/tenant-template`) via `Paths::project()`. The + `TENANCY_TEMPLATE_PATH` override is honoured as-is when absolute, or resolved + under the project root when relative. The previous plugin-directory fallback + was removed. + +> Tenant migrations against MySQL / SQL Server also required a companion fix in +> the `let-migrate` module (DDL implicitly commits, closing the open +> transaction) — released separately in that package. + ## [1.0.7] - 2026-07-11 ### Added diff --git a/plugins/Commands/Provider.php b/plugins/Commands/Provider.php index 90abfef..0d9947c 100644 --- a/plugins/Commands/Provider.php +++ b/plugins/Commands/Provider.php @@ -216,7 +216,16 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke // Register all 25+ migration commands. Pass the built instances // directly so their factory-injected dependencies are preserved // (re-instantiating via class-string would drop them). + // + // Yield to any command a plugin already claimed (queued at boot, + // before this deferred callback runs). This is why the kernel's + // generic LetMigrate `tenant:*` commands do NOT shadow the Tenancy + // plugin's registry-based equivalents when Tenancy is enabled — and + // they still register normally when it is not. foreach ($migrationFactory->all() as $commandInstance) { + if ($cli->hasQueued($commandInstance->getName())) { + continue; + } $cli->command($commandInstance); } }); diff --git a/plugins/Tenancy/Infrastructure/Cli/MigrateTenantsCommand.php b/plugins/Tenancy/Infrastructure/Cli/MigrateTenantsCommand.php index 9994fa5..8a23fdf 100644 --- a/plugins/Tenancy/Infrastructure/Cli/MigrateTenantsCommand.php +++ b/plugins/Tenancy/Infrastructure/Cli/MigrateTenantsCommand.php @@ -7,6 +7,7 @@ use AlfaCode\LetMigrate\MigrationServiceFactory; use AlfacodeTeam\PhpIoCli\AbstractCommand; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\EncryptionPort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\Tenancy\API\Contracts\TenantRegistryContract; use Plugins\Tenancy\Domain\Entities\Tenant; @@ -135,11 +136,20 @@ private function targets(): array private function defaultTemplatePath(): string { + // Env override: an absolute path is honoured as-is; a relative one is + // resolved under the active project root. $custom = env('TENANCY_TEMPLATE_PATH'); if (is_string($custom) && $custom !== '') { - return $custom; + return $this->isAbsolutePath($custom) ? $custom : Paths::project($custom); } - return dirname(__DIR__, 2) . '/database/tenant-template'; + // Project-relative by default: projects//database/tenant-template. + return Paths::project('database/tenant-template'); + } + + /** Unix (/…) or Windows (C:\… / \\…) absolute path. */ + private function isAbsolutePath(string $path): bool + { + return $path[0] === '/' || (bool) preg_match('#^[A-Za-z]:[\\\\/]|^\\\\\\\\#', $path); } } diff --git a/src/Kernel/Pipelines/Cli/CliPipeline.php b/src/Kernel/Pipelines/Cli/CliPipeline.php index 753cfe7..74eff30 100644 --- a/src/Kernel/Pipelines/Cli/CliPipeline.php +++ b/src/Kernel/Pipelines/Cli/CliPipeline.php @@ -81,6 +81,28 @@ public function command(string|AbstractCommand $command): void $this->commandClasses[] = $command; } + /** + * Whether a command with this name has already been queued (as an eager + * instance) or added to the underlying application. + * + * Lets a generic command provider (e.g. the migration factory) yield to a + * plugin that already claimed the name, so a plugin-owned command is never + * silently overwritten by a later last-wins registration. Only eager + * instances expose a name here; class-string registrations do not. + */ + public function hasQueued(string $name): bool + { + if ($this->app->has($name)) { + return true; + } + foreach ($this->eagerCommands as $command) { + if ($command->getName() === $name) { + return true; + } + } + return false; + } + /** * Defer command registration until the CLI is actually used. * From a79694db233b8663034370c2dc933d59b3f26ffa Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Mon, 13 Jul 2026 09:14:10 +0300 Subject: [PATCH 029/140] feat(audit,auth,social,tenancy,cli): audit plugin, device/mobile auth, social sign-in, default-tenant CLI, plugins update sync - New Audit plugin (audit.trail): single owner of the central audit_log table; User/Feedback/Tenancy now record through AuditServiceContract. Keyset-paginated reader + retention purge. - Auth: device sessions (/auth/sessions list/revoke + logout-other-devices, tenant auth_sessions table), mobile login/register/logout, OTP password reset (forgot/verify-otp/reset via CachePort broker), RoleResolver via the new authorization.policy dependency. - SocialAuth: full social sign-in (redirect/callback/native-token verification for GitHub/Google/Apple), central social_identities table, session or JWT+refresh response modes. - Authorization: SeedPolicyCommand + policy.seed.csv, HTTP stages, autoloaded Engine/functions.php helpers. - Tenant-scoped tables (Auth PATs/refresh/auth_sessions, Authorization casbin_rule, OAuth2 oauth_*) moved from central migrations to database/tenant-template. - User: membership-aware contract (checkMembership flags), outbox refactor into OutboxRelayService + OutboxRepository, email-verification page flow. - Tenancy CLI: var/tenants.json default tenant (tenant:create records, tenant:remember backfills, delete/host:add default to it). - hkm plugins update: full analyse + sync of config/database/resources/ui vs the project; refreshes drifted assets, re-syncs ui mirrors, migrates central + tenant DBs; independent central/tenant migrate passes. - Kernel: OnDemandLoader binds request-scoped client.ip for audit attribution. - Fix: AuthUserProxy withSecurity/withAccessToken dropped joinedAt (TypeError on every session/JWT guard resolve). --- composer.json | 1 + modules/let-migrate | 2 +- .../API/Contracts/AuditReaderContract.php} | 14 +- .../API/Contracts/AuditServiceContract.php | 34 ++ .../Application/Ports/AuditWriter.php | 10 +- .../Application/Services/AuditService.php | 84 +++++ .../Domain/Entities/AuditEntry.php | 2 +- .../Persistence/AuditLogRepository.php | 24 +- .../Infrastructure/Persistence/AuditTrail.php | 17 +- plugins/Audit/Provider.php | 98 +++++ ...26_06_22_000005_create_audit_log_table.php | 8 +- plugins/Audit/module.json | 17 + plugins/Auth/Application/Auth/AuthManager.php | 49 ++- .../Auth/Application/Auth/AuthUserProxy.php | 12 +- .../Auth/Application/Auth/GuardAccessor.php | 41 +++ .../Application/Auth/ModelUserProvider.php | 42 ++- .../Application/Auth/PasswordResetBroker.php | 43 +++ .../Auth/Application/Auth/RoleResolver.php | 42 +++ .../Application/Auth/StatefulSessionGuard.php | 31 +- plugins/Auth/Application/Ports/Driver.php | 66 ++++ .../Auth/Application/Ports/PasswordBroker.php | 19 + .../Auth/Application/Services/AuthService.php | 53 ++- .../Services/DeviceSessionService.php | 270 ++++++++++++++ .../Services/MobileAuthService.php | 126 +++++++ .../Services/RefreshTokenService.php | 71 +++- .../Http/Controllers/MobileAuthController.php | 139 ++++++++ .../Controllers/PasswordResetController.php | 109 ++++++ .../Controllers/SessionAuthController.php | 167 ++++++--- .../Http/Stages/SessionAuthStage.php | 14 + .../Persistence/DeviceSessionRepository.php | 189 ++++++++++ plugins/Auth/Provider.php | 134 ++++++- plugins/Auth/README.md | 86 ++++- plugins/Auth/config/auth.php | 22 ++ plugins/Auth/database/migrations/.gitkeep | 0 ...01_create_personal_access_tokens_table.php | 0 ...nd_abilities_to_personal_access_tokens.php | 0 ..._04_000002_create_refresh_tokens_table.php | 0 ...7_12_000001_create_auth_sessions_table.php | 55 +++ plugins/Auth/module.json | 26 +- plugins/Auth/resources/views/password-otp.php | 29 ++ .../AuthorizationServiceContract.php | 9 + .../Services/AuthorizationService.php | 20 ++ .../Engine/Log/Logger/DefaultLogger.php | 7 +- plugins/Authorization/Engine/functions.php | 78 ++++ .../Infrastructure/Cli/SeedPolicyCommand.php | 100 ++++++ .../Http/Stages/PolicyFilterStage.php | 63 ++++ plugins/Authorization/Provider.php | 44 ++- plugins/Authorization/config/policy.seed.csv | 175 +++++++++ plugins/Authorization/config/rbac_model.conf | 5 +- .../database/migrations/.gitkeep | 0 ..._06_05_000001_create_casbin_rule_table.php | 0 plugins/Authorization/module.json | 2 +- .../Application/Services/FeedbackService.php | 8 +- .../Infrastructure/Audit/AuditLogger.php | 106 ------ .../Persistence/FeedbackRepository.php | 7 +- plugins/Feedback/Provider.php | 24 +- plugins/Feedback/module.json | 57 ++- .../Application/Ports/AuthorizationFlow.php | 34 ++ .../Services/AuthorizationService.php | 25 +- plugins/OAuth2/Provider.php | 10 +- plugins/OAuth2/database/migrations/.gitkeep | 0 ...6_27_000010_create_oauth_clients_table.php | 0 ...7_000011_create_oauth_auth_codes_table.php | 0 ...0012_create_oauth_refresh_tokens_table.php | 0 ...06_27_000013_create_oauth_scopes_table.php | 0 ...000014_create_oauth_device_codes_table.php | 0 ...7_04_000001_add_owner_to_oauth_clients.php | 0 plugins/OAuth2/module.json | 5 +- .../Http/Stages/ApiRateLimitStage.php | 48 ++- .../Services/SocialLoginService.php | 144 ++++++++ .../Gateways/ProviderTokenGateway.php | 198 ++++++++++ .../Http/Controllers/SocialAuthController.php | 144 ++++++++ .../Persistence/SocialIdentityRepository.php | 102 ++++++ plugins/SocialAuth/Provider.php | 59 ++- ..._000001_create_social_identities_table.php | 50 +++ plugins/SocialAuth/module.json | 17 +- .../Contracts/MembershipServiceContract.php | 7 + plugins/Tenancy/API/DTOs/TenantSummary.php | 3 + .../Tenancy/Application/Ports/AuditSink.php | 23 -- .../Application/Services/AuditService.php | 49 --- .../Services/InvitationService.php | 4 +- .../Services/MembershipService.php | 17 +- .../Services/TenantHostService.php | 4 +- .../Tenancy/Domain/Entities/Membership.php | 38 +- .../Cli/AddTenantHostCommand.php | 29 +- .../Cli/CreateTenantCommand.php | 10 + .../Cli/DeleteTenantCommand.php | 22 +- .../Cli/RememberTenantCommand.php | 101 ++++++ .../Http/Stages/TenantContextStage.php | 20 +- .../Persistence/MembershipRepository.php | 2 +- .../TenantConnectionResolver.php | 1 + plugins/Tenancy/Provider.php | 103 +++--- plugins/Tenancy/README.md | 29 ++ plugins/Tenancy/Support/TenantsFile.php | 120 +++++++ plugins/Tenancy/module.json | 337 +++++++++++++----- .../API/Contracts/UserServiceContract.php | 41 ++- plugins/User/API/DTOs/RegisterUserDTO.php | 73 +++- plugins/User/API/DTOs/UserDTO.php | 10 + plugins/User/API/DTOs/VerifyEmailResult.php | 29 ++ plugins/User/Application/Ports/OutboxPort.php | 15 +- plugins/User/Application/Ports/UserStore.php | 7 +- .../Services/OutboxRelayService.php | 55 +++ .../User/Application/Services/UserService.php | 322 +++++++++++++---- .../Services/UserSettingsService.php | 13 +- plugins/User/Domain/Entities/User.php | 35 +- .../User/Infrastructure/Audit/AuditLogger.php | 106 ------ .../Cli/RelayUserOutboxCommand.php | 4 +- .../Http/Controllers/UserController.php | 136 ++++++- .../Http/Controllers/UserFlowController.php | 12 + .../Http/Controllers/UserPageController.php | 13 + .../Infrastructure/Outbox/OutboxRelay.php | 90 ----- .../Infrastructure/Outbox/OutboxWriter.php | 72 ---- .../Persistence/OutboxRepository.php | 121 +++++++ .../Persistence/UserRepository.php | 24 +- plugins/User/Provider.php | 64 ++-- ..._01_01_000001_create_user_outbox_table.php | 4 +- plugins/User/module.json | 321 ++++++++++++++--- .../User/resources/views/account/verify.php | 78 ++++ plugins/User/resources/views/layouts/app.php | 2 +- plugins/User/ui/site/Pages/User/Register.tsx | 12 + .../User/ui/site/Pages/User/VerifyEmail.tsx | 76 ++++ .../Support/Casting/Casts/DatetimeCast.php | 2 +- src/Kernel/Loading/OnDemandLoader.php | 12 +- .../Auth/ModelUserProviderTenantGateTest.php | 67 ++++ .../Auth/SessionAuthStageRememberTest.php | 6 +- .../Plugins/Auth/Support/FakeUserService.php | 24 +- .../Plugins/Feedback/FeedbackServiceTest.php | 4 +- .../Plugins/Tenancy/InvitationServiceTest.php | 6 +- .../Plugins/Tenancy/MembershipServiceTest.php | 4 +- .../Plugins/Tenancy/TenantHostServiceTest.php | 6 +- .../Unit/Plugins/User/Support/FakeOutbox.php | 14 +- .../Plugins/User/Support/FakeUserStore.php | 4 +- tests/Unit/Plugins/User/UserServiceTest.php | 15 +- .../Plugins/User/UserSettingsServiceTest.php | 4 +- tools/src/commands/plugins.zig | 92 ++++- tools/src/lib/plugin_assets.zig | 99 +++-- tools/src/lib/plugin_ui.zig | 23 ++ 137 files changed, 5611 insertions(+), 1112 deletions(-) rename plugins/{Tenancy/Application/Ports/AuditReader.php => Audit/API/Contracts/AuditReaderContract.php} (76%) create mode 100644 plugins/Audit/API/Contracts/AuditServiceContract.php rename plugins/{Tenancy => Audit}/Application/Ports/AuditWriter.php (55%) create mode 100644 plugins/Audit/Application/Services/AuditService.php rename plugins/{Tenancy => Audit}/Domain/Entities/AuditEntry.php (97%) rename plugins/{Tenancy => Audit}/Infrastructure/Persistence/AuditLogRepository.php (85%) rename plugins/{Tenancy => Audit}/Infrastructure/Persistence/AuditTrail.php (81%) create mode 100644 plugins/Audit/Provider.php rename plugins/{Tenancy => Audit}/database/migrations/2026_06_22_000005_create_audit_log_table.php (78%) create mode 100644 plugins/Audit/module.json create mode 100644 plugins/Auth/Application/Auth/RoleResolver.php create mode 100644 plugins/Auth/Application/Ports/Driver.php create mode 100644 plugins/Auth/Application/Services/DeviceSessionService.php create mode 100644 plugins/Auth/Application/Services/MobileAuthService.php create mode 100644 plugins/Auth/Infrastructure/Http/Controllers/MobileAuthController.php create mode 100644 plugins/Auth/Infrastructure/Http/Controllers/PasswordResetController.php create mode 100644 plugins/Auth/Infrastructure/Persistence/DeviceSessionRepository.php create mode 100644 plugins/Auth/database/migrations/.gitkeep rename plugins/Auth/database/{migrations => tenant-template}/2026_06_05_000001_create_personal_access_tokens_table.php (100%) rename plugins/Auth/database/{migrations => tenant-template}/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php (100%) rename plugins/Auth/database/{migrations => tenant-template}/2026_07_04_000002_create_refresh_tokens_table.php (100%) create mode 100644 plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php create mode 100644 plugins/Auth/resources/views/password-otp.php create mode 100644 plugins/Authorization/Engine/functions.php create mode 100644 plugins/Authorization/Infrastructure/Cli/SeedPolicyCommand.php create mode 100644 plugins/Authorization/Infrastructure/Http/Stages/PolicyFilterStage.php create mode 100644 plugins/Authorization/config/policy.seed.csv create mode 100644 plugins/Authorization/database/migrations/.gitkeep rename plugins/Authorization/database/{migrations => tenant-template}/2026_06_05_000001_create_casbin_rule_table.php (100%) delete mode 100644 plugins/Feedback/Infrastructure/Audit/AuditLogger.php create mode 100644 plugins/OAuth2/Application/Ports/AuthorizationFlow.php create mode 100644 plugins/OAuth2/database/migrations/.gitkeep rename plugins/OAuth2/database/{migrations => tenant-template}/2026_06_27_000010_create_oauth_clients_table.php (100%) rename plugins/OAuth2/database/{migrations => tenant-template}/2026_06_27_000011_create_oauth_auth_codes_table.php (100%) rename plugins/OAuth2/database/{migrations => tenant-template}/2026_06_27_000012_create_oauth_refresh_tokens_table.php (100%) rename plugins/OAuth2/database/{migrations => tenant-template}/2026_06_27_000013_create_oauth_scopes_table.php (100%) rename plugins/OAuth2/database/{migrations => tenant-template}/2026_06_27_000014_create_oauth_device_codes_table.php (100%) rename plugins/OAuth2/database/{migrations => tenant-template}/2026_07_04_000001_add_owner_to_oauth_clients.php (100%) create mode 100644 plugins/SocialAuth/Application/Services/SocialLoginService.php create mode 100644 plugins/SocialAuth/Infrastructure/Gateways/ProviderTokenGateway.php create mode 100644 plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php create mode 100644 plugins/SocialAuth/Infrastructure/Persistence/SocialIdentityRepository.php create mode 100644 plugins/SocialAuth/database/migrations/2026_07_12_000001_create_social_identities_table.php delete mode 100644 plugins/Tenancy/Application/Ports/AuditSink.php delete mode 100644 plugins/Tenancy/Application/Services/AuditService.php create mode 100644 plugins/Tenancy/Infrastructure/Cli/RememberTenantCommand.php create mode 100644 plugins/Tenancy/Support/TenantsFile.php create mode 100644 plugins/User/API/DTOs/VerifyEmailResult.php create mode 100644 plugins/User/Application/Services/OutboxRelayService.php delete mode 100644 plugins/User/Infrastructure/Audit/AuditLogger.php delete mode 100644 plugins/User/Infrastructure/Outbox/OutboxRelay.php delete mode 100644 plugins/User/Infrastructure/Outbox/OutboxWriter.php create mode 100644 plugins/User/Infrastructure/Persistence/OutboxRepository.php create mode 100644 plugins/User/resources/views/account/verify.php create mode 100644 plugins/User/ui/site/Pages/User/VerifyEmail.tsx create mode 100644 tests/Unit/Plugins/Auth/ModelUserProviderTenantGateTest.php diff --git a/composer.json b/composer.json index c915202..2713316 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "src/Kernel/Support/helpers.php", "plugins/I18n/Support/helpers.php", "plugins/Auth/Support/helpers.php", + "plugins/Authorization/Engine/functions.php", "plugins/Cookie/Support/helpers.php", "plugins/Pageflow/Support/helpers.php" ], diff --git a/modules/let-migrate b/modules/let-migrate index eccb9d8..68f72db 160000 --- a/modules/let-migrate +++ b/modules/let-migrate @@ -1 +1 @@ -Subproject commit eccb9d87a3fcd4b86b10d370f5fb94ee575945c9 +Subproject commit 68f72dbeb9740a814e7c47c937c955b3f130edda diff --git a/plugins/Tenancy/Application/Ports/AuditReader.php b/plugins/Audit/API/Contracts/AuditReaderContract.php similarity index 76% rename from plugins/Tenancy/Application/Ports/AuditReader.php rename to plugins/Audit/API/Contracts/AuditReaderContract.php index 2fb15ea..ed375dd 100644 --- a/plugins/Tenancy/Application/Ports/AuditReader.php +++ b/plugins/Audit/API/Contracts/AuditReaderContract.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Plugins\Tenancy\Application\Ports; +namespace Plugins\Audit\API\Contracts; -use Plugins\Tenancy\Domain\Entities\AuditEntry; +use Plugins\Audit\Domain\Entities\AuditEntry; /** - * Read/query seam for the central `audit_log` trail (the counterpart to the - * write-side {@see AuditSink}). Lets the control plane surface audit history — - * per tenant, per user, per action — and prune it for retention, without - * coupling callers to SQL. + * Published read/query contract for the central `audit_log` trail (the + * counterpart to {@see AuditServiceContract}). Lets a control plane surface + * audit history — per tenant, per user, per action — and prune it for + * retention, without coupling callers to SQL. * * All listings are keyset-paginated by descending id (id is monotonic with * occurred_at) — pass the last seen id as $beforeId to fetch the next page. */ -interface AuditReader +interface AuditReaderContract { /** @return list Newest first across the whole trail. */ public function recent(int $limit = 50, ?int $beforeId = null): array; diff --git a/plugins/Audit/API/Contracts/AuditServiceContract.php b/plugins/Audit/API/Contracts/AuditServiceContract.php new file mode 100644 index 0000000..1ab6cd9 --- /dev/null +++ b/plugins/Audit/API/Contracts/AuditServiceContract.php @@ -0,0 +1,34 @@ + $meta structured, non-PII context + */ + public function record( + string $action, + ?string $userId = null, + ?string $tenantId = null, + array $meta = [], + ?string $ip = null, + ): void; +} diff --git a/plugins/Tenancy/Application/Ports/AuditWriter.php b/plugins/Audit/Application/Ports/AuditWriter.php similarity index 55% rename from plugins/Tenancy/Application/Ports/AuditWriter.php rename to plugins/Audit/Application/Ports/AuditWriter.php index 0dc8cfa..47a6fa5 100644 --- a/plugins/Tenancy/Application/Ports/AuditWriter.php +++ b/plugins/Audit/Application/Ports/AuditWriter.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace Plugins\Tenancy\Application\Ports; +namespace Plugins\Audit\Application\Ports; /** * Persistence seam for the append-only central `audit_log` trail (write side). * - * Implemented by {@see \Plugins\Tenancy\Infrastructure\Persistence\AuditTrail}, - * consumed ONLY by {@see \Plugins\Tenancy\Application\Services\AuditService}. - * The repository MAY throw (RepositoryException) — the best-effort policy lives - * in the service, not here. + * Implemented by {@see \Plugins\Audit\Infrastructure\Persistence\AuditTrail}, + * consumed ONLY by {@see \Plugins\Audit\Application\Services\AuditService}. The + * repository MAY throw (RepositoryException) — the best-effort policy lives in + * the service, not here. */ interface AuditWriter { diff --git a/plugins/Audit/Application/Services/AuditService.php b/plugins/Audit/Application/Services/AuditService.php new file mode 100644 index 0000000..a6034f2 --- /dev/null +++ b/plugins/Audit/Application/Services/AuditService.php @@ -0,0 +1,84 @@ +sink = $sink ?? static fn (string $line) => error_log($line); + } + + public function record( + string $action, + ?string $userId = null, + ?string $tenantId = null, + array $meta = [], + ?string $ip = null, + ): void { + $userId ??= ($this->actorId ?: null); + $tenantId ??= ($this->currentTenant !== null && $this->currentTenant !== '' ? $this->currentTenant : null); + $ip ??= ($this->clientIp !== null && $this->clientIp !== '' ? $this->clientIp : null); + + $line = json_encode([ + 'source' => 'audit', + 'action' => $action, + 'user' => $userId, + 'tenant' => $tenantId, + 'ip' => $ip, + 'meta' => $meta, + 'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339), + ], JSON_UNESCAPED_SLASHES); + + if ($line !== false) { + ($this->sink)($line); + } + + if ($this->writer === null) { + return; + } + + try { + $this->writer->write($action, $userId, $tenantId, $meta, $ip); + } catch (\Throwable) { + // Best-effort — the log line above is the durable fallback. + } + } +} diff --git a/plugins/Tenancy/Domain/Entities/AuditEntry.php b/plugins/Audit/Domain/Entities/AuditEntry.php similarity index 97% rename from plugins/Tenancy/Domain/Entities/AuditEntry.php rename to plugins/Audit/Domain/Entities/AuditEntry.php index e22ad22..f4298f0 100644 --- a/plugins/Tenancy/Domain/Entities/AuditEntry.php +++ b/plugins/Audit/Domain/Entities/AuditEntry.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Plugins\Tenancy\Domain\Entities; +namespace Plugins\Audit\Domain\Entities; use Project\Support\Entity\Entity; diff --git a/plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php b/plugins/Audit/Infrastructure/Persistence/AuditLogRepository.php similarity index 85% rename from plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php rename to plugins/Audit/Infrastructure/Persistence/AuditLogRepository.php index 18ee772..cac6e86 100644 --- a/plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php +++ b/plugins/Audit/Infrastructure/Persistence/AuditLogRepository.php @@ -2,27 +2,27 @@ declare(strict_types=1); -namespace Plugins\Tenancy\Infrastructure\Persistence; +namespace Plugins\Audit\Infrastructure\Persistence; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\Tenancy\Application\Ports\AuditReader; -use Plugins\Tenancy\Domain\Entities\AuditEntry; +use Plugins\Audit\API\Contracts\AuditReaderContract; +use Plugins\Audit\Domain\Entities\AuditEntry; /** * AuditLogRepository — read/query side of the central `audit_log` trail. * * Access rule: DatabasePort ONLY (the CENTRAL connection — the trail lives in - * the control-plane DB, never a tenant DB). Writes go through - * {@see AuditTrail}; this is the read - * counterpart, leaning on the (tenant_id|user_id|action, occurred_at) indexes. + * the control-plane DB, never a tenant DB). Writes go through {@see AuditTrail}; + * this is the read counterpart, leaning on the + * (tenant_id|user_id|action, occurred_at) indexes. * * Listings are keyset-paginated by descending id (monotonic with occurred_at): * pass the last id seen as $beforeId for the next page. LIMIT is clamped and * inlined as an integer — it cannot be a bound parameter with emulated prepares * disabled — while all filter VALUES stay parameter-bound. */ -final class AuditLogRepository implements AuditReader +final class AuditLogRepository implements AuditReaderContract { private const SELECT = 'SELECT id, event_id, user_id, tenant_id, action, ip, meta, occurred_at FROM audit_log'; @@ -61,7 +61,7 @@ public function find(string $eventId): ?AuditEntry ['event_id' => $eventId], ); } catch (\Throwable $e) { - throw new RepositoryException('Failed to load audit entry.', layer: 'repository.tenancy', previous: $e); + throw new RepositoryException('Failed to load audit entry.', layer: 'repository.audit', previous: $e); } return $row === null ? null : AuditEntry::fromRow($row); @@ -75,7 +75,7 @@ public function countForTenant(string $tenantId): int ['tenant_id' => $tenantId], ); } catch (\Throwable $e) { - throw new RepositoryException('Failed to count audit entries.', layer: 'repository.tenancy', previous: $e); + throw new RepositoryException('Failed to count audit entries.', layer: 'repository.audit', previous: $e); } return (int) ($row['c'] ?? 0); @@ -89,7 +89,7 @@ public function purgeOlderThan(\DateTimeImmutable $cutoff): int ['cutoff' => $cutoff->format('Y-m-d H:i:s')], ); } catch (\Throwable $e) { - throw new RepositoryException('Failed to purge audit entries.', layer: 'repository.tenancy', previous: $e); + throw new RepositoryException('Failed to purge audit entries.', layer: 'repository.audit', previous: $e); } } @@ -101,7 +101,7 @@ public function purgeOlderThan(\DateTimeImmutable $cutoff): int */ private function page(string $where, array $params, int $limit, ?int $beforeId): array { - $limit = max(1, min(self::MAX_LIMIT, $limit)); + $limit = max(1, min(self::MAX_LIMIT, $limit)); $clauses = $where !== '' ? [$where] : []; if ($beforeId !== null) { @@ -115,7 +115,7 @@ private function page(string $where, array $params, int $limit, ?int $beforeId): try { $rows = $this->central->query($sql, $params); } catch (\Throwable $e) { - throw new RepositoryException('Failed to list audit entries.', layer: 'repository.tenancy', previous: $e); + throw new RepositoryException('Failed to list audit entries.', layer: 'repository.audit', previous: $e); } return array_map(static fn (array $r): AuditEntry => AuditEntry::fromRow($r), $rows); diff --git a/plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php b/plugins/Audit/Infrastructure/Persistence/AuditTrail.php similarity index 81% rename from plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php rename to plugins/Audit/Infrastructure/Persistence/AuditTrail.php index 70b4645..dc3a2f1 100644 --- a/plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php +++ b/plugins/Audit/Infrastructure/Persistence/AuditTrail.php @@ -2,12 +2,11 @@ declare(strict_types=1); -namespace Plugins\Tenancy\Infrastructure\Persistence; +namespace Plugins\Audit\Infrastructure\Persistence; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\Tenancy\Application\Ports\AuditWriter; -use Plugins\Tenancy\Support\Token; +use Plugins\Audit\Application\Ports\AuditWriter; /** * AuditTrail — append-only writer for the central `audit_log` table. @@ -19,7 +18,7 @@ * Pure persistence: a failure is translated to RepositoryException (like the * read sibling) and rethrown. The best-effort policy — never letting an audit * write break the action it records — lives one layer up in - * {@see \Plugins\Tenancy\Application\Services\AuditService}. + * {@see \Plugins\Audit\Application\Services\AuditService}. */ final class AuditTrail implements AuditWriter { @@ -39,7 +38,7 @@ public function write( 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) VALUES (:eid, :uid, :tid, :action, :ip, :meta, :ts)', [ - 'eid' => Token::ulid(), + 'eid' => self::eventId(), 'uid' => $userId, 'tid' => $tenantId, 'action' => $action, @@ -51,10 +50,16 @@ public function write( } catch (\Throwable $e) { throw new RepositoryException( 'Failed to write audit entry.', - layer: 'repository.tenancy', + layer: 'repository.audit', context: ['action' => $action], previous: $e, ); } } + + /** Unique event id for the row (fits the char(31) column). */ + private static function eventId(): string + { + return bin2hex(random_bytes(15)); // 30 hex chars + } } diff --git a/plugins/Audit/Provider.php b/plugins/Audit/Provider.php new file mode 100644 index 0000000..5297b05 --- /dev/null +++ b/plugins/Audit/Provider.php @@ -0,0 +1,98 @@ + */ + public function requires(): array + { + return [DatabaseConnectionManagerContract::class]; + } + + /** @return list */ + public function exposes(): array + { + return [AuditServiceContract::class, AuditReaderContract::class]; + } + + public function register(ModuleContainer $container): void + { + // Write side: persistence seam behind the audit service (central conn). + $container->bindInternal(AuditWriter::class, static fn (ModuleContainer $c): AuditWriter => + new AuditTrail(self::central($c))); + + // Published write contract — the ONE way any plugin records an action. + // Auto-fills actor (Identity) and tenant (Tenancy's `tenant.current` + // container key — a plain string, no Tenancy import) when omitted. + $container->bind(AuditServiceContract::class, static function (ModuleContainer $c): AuditServiceContract { + $identity = $c->has(Identity::class) ? $c->make(Identity::class) : null; + $actorId = $identity !== null ? ($identity->userId ?: null) : null; + + // Tenant source: the routed tenant (`tenant.current`, set by Tenancy's + // TenantContextStage) when present, else the authoritative Identity + // tenant — which is always bound at load and drives the routing itself, + // so it is populated even on paths where the stage bound nothing. + $tenantId = $c->has('tenant.current') + ? ((string) $c->make('tenant.current') ?: null) + : ($identity !== null ? ($identity->tenantId ?: null) : null); + + $clientIp = $c->has('client.ip') ? (string) $c->make('client.ip') : null; + + return new AuditService( + writer: $c->make(AuditWriter::class), + actorId: $actorId, + currentTenant: $tenantId, + clientIp: $clientIp, + ); + }); + + // Published read/query contract for control-plane admin surfaces. + $container->bind(AuditReaderContract::class, static fn (ModuleContainer $c): AuditReaderContract => + new AuditLogRepository(self::central($c))); + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + // No pipeline hooks or subscriptions — a pure infrastructure domain. + } + + /** The CENTRAL connection (owns the shared `audit_log` table). */ + private static function central(ModuleContainer $c): DatabasePort + { + return $c->make(DatabaseConnectionManagerContract::class)->default(); + } +} diff --git a/plugins/Tenancy/database/migrations/2026_06_22_000005_create_audit_log_table.php b/plugins/Audit/database/migrations/2026_06_22_000005_create_audit_log_table.php similarity index 78% rename from plugins/Tenancy/database/migrations/2026_06_22_000005_create_audit_log_table.php rename to plugins/Audit/database/migrations/2026_06_22_000005_create_audit_log_table.php index c7889fb..0f44421 100644 --- a/plugins/Tenancy/database/migrations/2026_06_22_000005_create_audit_log_table.php +++ b/plugins/Audit/database/migrations/2026_06_22_000005_create_audit_log_table.php @@ -6,11 +6,13 @@ use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; /** - * Tenancy — central `audit_log` (CONTROL PLANE), append-only. + * Audit — central `audit_log` (CONTROL PLANE), append-only. * * Attributable trail for security/compliance: login, tenant.switch, - * tenant.create, member.invite, etc. Lives central so cross-tenant admin actions - * are captured in one place. Stores identifiers + structured meta only — no + * tenant.create, member.invite, user.registered, feedback.submitted, etc. Lives + * central so cross-tenant admin actions are captured in one place. Owned by the + * Audit plugin (solves audit.trail); User/Feedback/Tenancy write it ONLY through + * AuditServiceContract. Stores identifiers + structured meta only — no * passwords/tokens/PII payloads. */ return new class implements MigrationInterface { diff --git a/plugins/Audit/module.json b/plugins/Audit/module.json new file mode 100644 index 0000000..602e71c --- /dev/null +++ b/plugins/Audit/module.json @@ -0,0 +1,17 @@ +{ + "name": "audit", + "version": "1.0.0", + "solves": "audit.trail", + "type": "module", + + "requires": ["database.management"], + "exposes": ["AuditServiceContract", "AuditReaderContract"], + + "routes": [], + "emits": [], + "listens": [], + + "documentation": "The Audit plugin — owns the audit.trail domain and the shared central `audit_log` table. It is the SINGLE writer/reader of that table: other plugins (User, Feedback, Tenancy) require `audit.trail` and record security-relevant actions through the published AuditServiceContract instead of writing the table themselves (which would duplicate the writer and violate table ownership). Records identifiers + structured meta only — never passwords, hashes, tokens, or raw PII — so the trail is safe to ship to a SIEM. Each entry is emitted as a JSON log line (source=audit) AND persisted best-effort to `audit_log` (an audit write never breaks the action it records). The service auto-fills actor (Identity) and tenant (TenantContextStage's `tenant.current`) when a caller does not pass them. AuditReaderContract exposes keyset-paginated queries + retention purge for control-plane admin surfaces. Enabling publishes database/ (the central audit_log migration).", + + "config": [] +} diff --git a/plugins/Auth/Application/Auth/AuthManager.php b/plugins/Auth/Application/Auth/AuthManager.php index 916cb51..69003fb 100644 --- a/plugins/Auth/Application/Auth/AuthManager.php +++ b/plugins/Auth/Application/Auth/AuthManager.php @@ -10,6 +10,7 @@ use Plugins\Auth\Application\Ports\Authenticatable; use Plugins\Auth\Application\Ports\GuardContext; use Plugins\Auth\Application\Ports\GuardDriver; +use Plugins\Auth\Application\Ports\StatefulGuard; use Plugins\Auth\Application\Ports\UserProvider; /** @@ -57,12 +58,18 @@ final class AuthManager /** * @param array $config auth_config() * @param \Closure(string): ?UserProvider $providerFactory builds a named provider + * @param \Closure(string,UserProvider,Request): ?StatefulGuard $statefulFactory + * builds the WRITE-side guard (attempt/login/logout) for stateful + * drivers — wired by the Provider with the module's collaborators. */ public function __construct( private readonly array $config, private readonly \Closure $providerFactory, private readonly ?SessionPort $session = null, private readonly ?\Plugins\Auth\API\Contracts\AuthServiceContract $auth = null, + private readonly ?\Closure $statefulFactory = null, + private readonly ?\Plugins\Auth\API\Contracts\RefreshTokenServiceContract $refreshTokens = null, + private readonly int $accessTtl = 3600, ) {} /** @@ -80,6 +87,36 @@ public function issueToken(string $userId, array $claims = [], int $ttlSeconds = return $this->auth->issueJwt($userId, $claims, $ttlSeconds); } + /** + * Mint the full mobile/API credential pair for an ALREADY-VERIFIED user: + * a short-lived access JWT + a revocable refresh token. The single front-door + * call for stateless issuance (mobile login/register), so callers never touch + * AuthService / RefreshTokenService directly. + * + * @param array{roles?:list,permissions?:list,tnt?:string} $claims + * @return array{accessToken:string,tokenType:string,expiresAt:int,refreshToken:string,refreshExpiresAt:string} + */ + public function issueTokenPair(string $userId, array $claims = [], ?string $device = null, ?string $ip = null): array + { + if ($this->auth === null || $this->refreshTokens === null) { + throw new ServiceException( + 'AuthManager cannot issue a token pair — AuthService/RefreshTokenService not wired.', + layer: 'service.auth', + ); + } + + $accessToken = $this->auth->issueJwt($userId, $claims, $this->accessTtl); + $refresh = $this->refreshTokens->issue($userId, device: $device, ip: $ip); + + return [ + 'accessToken' => $accessToken, + 'tokenType' => 'Bearer', + 'expiresAt' => time() + $this->accessTtl, + 'refreshToken' => $refresh->token, + 'refreshExpiresAt' => $refresh->expiresAt, + ]; + } + /** * Bind the active request (the container-bearing one) and reset the guard * cache. Called once per request by the controller concern; resetting the @@ -167,7 +204,7 @@ public function forgetGuards(): self { $this->guards = []; - return $this; + return $this; } /** Forward unknown calls (check/user/id/identity/...) to the default guard. */ @@ -224,9 +261,15 @@ private function buildGuard(string $name): GuardAccessor /** @var GuardDriver $driver */ $driver = new $driverClass(); - return new GuardAccessor($name, $driver, $context, $this->request); - } + // Stateful drivers also get the WRITE-side guard so the old ergonomics + // hold: $manager->guard('web')->attempt($credentials, $remember). + $stateful = $driverName === 'session' && $this->statefulFactory !== null + ? ($this->statefulFactory)($name, $provider, $this->request) + : null; + return new GuardAccessor($name, $driver, $context, $this->request, $stateful); + } + private function defaultGuard(): string { return (string) ($this->config['defaults']['guard'] ?? 'web'); diff --git a/plugins/Auth/Application/Auth/AuthUserProxy.php b/plugins/Auth/Application/Auth/AuthUserProxy.php index 02028c8..2a640ee 100644 --- a/plugins/Auth/Application/Auth/AuthUserProxy.php +++ b/plugins/Auth/Application/Auth/AuthUserProxy.php @@ -34,9 +34,10 @@ private function __construct( private string $username, private string $email, private array $roles, - private array $permissions, + private array $permissions, private string $tenantId, private string $tokenType, + private string $joinedAt, private ?AuthServiceContract $tokensService = null, private ?TokenDTO $accessToken = null, ) {} @@ -50,9 +51,7 @@ private function __construct( */ public static function fromUser( UserDTO $user, - array $roles = [], array $permissions = [], - string $tenantId = '', string $tokenType = 'session', ?AuthServiceContract $tokensService = null, ): self { @@ -60,11 +59,12 @@ public static function fromUser( userId: $user->id, username: $user->username, email: $user->email, - roles: array_values($roles), + roles: array_values($user->roles), permissions: array_values($permissions), - tenantId: $tenantId, + tenantId: $user->tenantId ?? "", tokenType: $tokenType, tokensService: $tokensService, + joinedAt: $user->joinedAt ?? "", ); } @@ -85,6 +85,7 @@ public function withSecurity(array $roles, array $permissions, string $tenantId, array_values($permissions), $tenantId, $tokenType, + $this->joinedAt, $this->tokensService, $this->accessToken, ); @@ -101,6 +102,7 @@ public function withAccessToken(TokenDTO $token): self $this->permissions, $this->tenantId, $this->tokenType, + $this->joinedAt, $this->tokensService, $token, ); diff --git a/plugins/Auth/Application/Auth/GuardAccessor.php b/plugins/Auth/Application/Auth/GuardAccessor.php index 2824042..4e5f54e 100644 --- a/plugins/Auth/Application/Auth/GuardAccessor.php +++ b/plugins/Auth/Application/Auth/GuardAccessor.php @@ -4,16 +4,28 @@ namespace Plugins\Auth\Application\Auth; +use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ServiceException; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; use Plugins\Auth\Application\Ports\Authenticatable; use Plugins\Auth\Application\Ports\GuardContext; use Plugins\Auth\Application\Ports\GuardDriver; +use Plugins\Auth\Application\Ports\StatefulGuard; /** * GuardAccessor — the per-guard facade AuthManager::guard($name) returns, * preserving the old `$manager->guard('web')->user()` ergonomics. Resolves the * user lazily once per accessor and caches it (request-scoped). + * + * Stateful guards ('session' driver) also carry the WRITE-side guard, so the + * full old flow works through one handle: + * + * $manager->guard('web')->attempt(['email' => …, 'password' => …], remember: true); + * $manager->guard('web')->logout(); + * $manager->guard('web')->logoutOtherDevices($password); + * + * Write calls are forwarded via __call; a stateless guard (api/jwt/request) + * throws a descriptive ServiceException instead of silently no-opping. */ final class GuardAccessor { @@ -25,8 +37,37 @@ public function __construct( private readonly GuardDriver $driver, private readonly GuardContext $context, private readonly Request $request, + private readonly ?StatefulGuard $stateful = null, ) {} + /** + * The WRITE-side guard (attempt/login/logout/…), when this guard is + * stateful. Null for token-style guards. + */ + public function stateful(): ?StatefulGuard + { + return $this->stateful; + } + + /** Forward write operations (attempt/login/logout/…) to the stateful guard. */ + public function __call(string $method, array $parameters): mixed + { + if ($this->stateful === null) { + throw new ServiceException( + "Auth guard [{$this->name}] is stateless — [{$method}] requires a session guard.", + layer: 'service.auth', + ); + } + + $result = $this->stateful->{$method}(...$parameters); + + // A write may have changed who is logged in — drop the cached read. + $this->resolved = false; + $this->user = null; + + return $result; + } + /** Guard name (e.g. 'web', 'api'). */ public function name(): string { diff --git a/plugins/Auth/Application/Auth/ModelUserProvider.php b/plugins/Auth/Application/Auth/ModelUserProvider.php index a837afd..80ca00b 100644 --- a/plugins/Auth/Application/Auth/ModelUserProvider.php +++ b/plugins/Auth/Application/Auth/ModelUserProvider.php @@ -17,10 +17,19 @@ * lookup to the published User contract (which is timing-safe, rate-limited and * hides the password hash) and returns a lightweight AuthUserProxy. No entity * hydration, no `app()`, no global project entity. + * + * Tenant-membership gate: membership is part of the FETCH — id lookups pass + * checkMembership=true to the User contract, so on a tenant-scoped request a + * user without an active seat in that tenant simply does not exist for + * authentication purposes (retrieve* returns null, indistinguishable from a + * missing user). Credential/remember-token lookups enforce the same rule + * inside the User service. */ final class ModelUserProvider implements UserProvider { - /** @param list $lookupFields ordered credential keys to try */ + /** + * @param list $lookupFields ordered credential keys to try + */ public function __construct( private readonly UserServiceContract $users, private readonly string $providerName = 'users', @@ -39,16 +48,12 @@ public function retrieveById(string $id): ?Authenticatable return null; } - $user = $this->users->find($id); - - return $user === null ? null : AuthUserProxy::fromUser($user, tokensService: $this->tokens); + return $this->proxy($this->users->find($id, true)); } public function retrieveByToken(string $rememberToken): ?Authenticatable { - $user = $this->users->findByRememberToken($rememberToken); - - return $user === null ? null : AuthUserProxy::fromUser($user, tokensService: $this->tokens); + return $this->proxy($this->users->findByRememberToken($rememberToken)); } public function retrieveByCredentials(array $credentials): ?Authenticatable @@ -66,10 +71,29 @@ public function retrieveByCredentials(array $credentials): ?Authenticatable return null; } + $user = $this->users->verifyCredentials($identifier, $password); + + // Single timing-safe verify (unknown user, wrong password, inactive, or // lockout all return null). The store never exposes the hash. - $user = $this->users->verifyCredentials($identifier, $password); + return $this->proxy($user); + } + + /** + * Wrap a fetched user in the auth proxy. Membership was already enforced by + * the User contract during the fetch, so a user without an active seat in + * the request's tenant arrives here as null — indistinguishable from a + * non-existent user. + */ + private function proxy(?\Plugins\User\API\DTOs\UserDTO $user): ?Authenticatable + { + if ($user === null) { + return null; + } - return $user === null ? null : AuthUserProxy::fromUser($user, tokensService: $this->tokens); + return AuthUserProxy::fromUser( + $user, + tokensService: $this->tokens, + ); } } diff --git a/plugins/Auth/Application/Auth/PasswordResetBroker.php b/plugins/Auth/Application/Auth/PasswordResetBroker.php index 97ef993..ff6a96c 100644 --- a/plugins/Auth/Application/Auth/PasswordResetBroker.php +++ b/plugins/Auth/Application/Auth/PasswordResetBroker.php @@ -20,12 +20,14 @@ final class PasswordResetBroker implements PasswordBroker { private const TOKEN_PREFIX = 'auth:pwreset:tok:'; private const THROTTLE_PREFIX = 'auth:pwreset:thr:'; + private const OTP_PREFIX = 'auth:pwreset:otp:'; public function __construct( private readonly UserServiceContract $users, private readonly CachePort $cache, private readonly int $ttlSeconds = 3600, private readonly int $throttleSeconds = 60, + private readonly int $otpTtlSeconds = 600, ) {} public function sendResetLink(string $email): array @@ -85,6 +87,47 @@ public function reset(string $email, string $token, string $newPassword): string return self::PASSWORD_RESET; } + // ── OTP mode (old __DEV__ mobile forgot-password flow) ────────────────────── + + public function sendOtp(string $email): ?array + { + $result = $this->sendResetLink($email); + if (($result['status'] ?? '') !== self::RESET_LINK_SENT || !isset($result['token'])) { + return null; // unknown user or throttled — caller responds generically + } + + // 6-digit OTP paired with the underlying reset token: "otp|token", + // short-lived and single-use (consumed by verifyOtp). + $otp = str_pad((string) random_int(0, 999_999), 6, '0', STR_PAD_LEFT); + $this->cache->set( + self::OTP_PREFIX . $this->key((string) $result['email']), + $otp . '|' . $result['token'], + $this->otpTtlSeconds, + ); + + return ['otp' => $otp, 'email' => (string) $result['email']]; + } + + public function verifyOtp(string $email, string $otp): ?string + { + $email = mb_strtolower(trim($email)); + $cached = $this->cache->get(self::OTP_PREFIX . $this->key($email)); + + if (!is_string($cached) || $cached === '') { + return null; + } + + [$storedOtp, $token] = explode('|', $cached, 2) + [1 => '']; + if ($token === '' || !hash_equals($storedOtp, trim($otp))) { + return null; + } + + // Single-use: burn the OTP; the underlying token stays valid for reset(). + $this->cache->delete(self::OTP_PREFIX . $this->key($email)); + + return $token; + } + private function key(string $email): string { return hash('sha256', $email); diff --git a/plugins/Auth/Application/Auth/RoleResolver.php b/plugins/Auth/Application/Auth/RoleResolver.php new file mode 100644 index 0000000..d5158d5 --- /dev/null +++ b/plugins/Auth/Application/Auth/RoleResolver.php @@ -0,0 +1,42 @@ +hasRole/hasPermission, the `can` filter, service gates) see + * the same picture. When Authorization is absent it degrades to empty lists — + * auth still works, there is simply no RBAC data to carry. + */ +final class RoleResolver +{ + public function __construct( + private readonly ?AuthorizationServiceContract $authz = null, + ) { + } + + /** + * @return array{roles: list, permissions: list} + */ + public function forUser(string $userId, string $tenantId = ''): array + { + if ($this->authz === null || $userId === '') { + return ['roles' => [], 'permissions' => []]; + } + + $domain = $tenantId !== '' ? $tenantId : null; + + return [ + 'roles' => $this->authz->rolesOf($userId, $domain), + 'permissions' => $this->authz->permissionsOf($userId, $domain), + ]; + } +} diff --git a/plugins/Auth/Application/Auth/StatefulSessionGuard.php b/plugins/Auth/Application/Auth/StatefulSessionGuard.php index b389314..399ca7f 100644 --- a/plugins/Auth/Application/Auth/StatefulSessionGuard.php +++ b/plugins/Auth/Application/Auth/StatefulSessionGuard.php @@ -12,6 +12,7 @@ use Plugins\Auth\Application\Ports\SupportsBasicAuth; use Plugins\Auth\Application\Ports\UserProvider; use Plugins\Auth\Application\Services\AuthService; +use Plugins\Auth\Application\Services\DeviceSessionService; use Plugins\Auth\Domain\ValueObjects\Recaller; use Plugins\Auth\Infrastructure\Http\Stages\SessionAuthStage; use Plugins\Cookie\Infrastructure\CookieJar; @@ -44,6 +45,7 @@ public function __construct( private readonly ?CookieJar $cookies = null, private readonly string $recallerCookie = SessionAuthStage::RECALLER_COOKIE, private readonly int $rememberTtl = SessionAuthStage::RECALLER_TTL, + private readonly ?DeviceSessionService $devices = null, ) { $this->provider = $provider; } @@ -69,10 +71,20 @@ public function user(): ?Authenticatable { if ($this->user !== null) { return $this->user; - } + } $userId = (string) $this->session->get(AuthService::SESSION_USER, ''); if ($userId !== '') { + // Fingerprint + server-side device-session validation (old __DEV__ + // semantics): a hijacked or revoked session dies here, immediately. + if ($this->devices !== null && $this->request !== null + && !$this->devices->verify($this->session, $this->request)) { + $this->devices->teardown($this->session); + $this->session->invalidate(); + + return null; + } + $base = $this->provider->retrieveById($userId); if ($base instanceof AuthUserProxy) { $base = $base->withSecurity( @@ -94,6 +106,7 @@ public function user(): ?Authenticatable public function attempt(array $credentials = [], bool $remember = false): bool { $user = $this->provider->retrieveByCredentials($credentials); + $this->lastAttempted = $user; if ($user === null) { @@ -159,6 +172,11 @@ public function login(Authenticatable $user, bool $remember = false): void $this->session->put(AuthService::SESSION_PERMISSIONS, $identity->permissions); $this->session->put(AuthService::SESSION_TENANT, $identity->tenantId); + // Bind the session to this device: fingerprint + auth_sessions row. + if ($this->devices !== null && $this->request !== null) { + $this->devices->establish($this->session, $this->request, $identity->userId); + } + if ($remember) { $this->queueRecaller($identity->userId); } @@ -176,6 +194,7 @@ public function logout(): void $this->users->clearRememberToken($userId); } $this->cookies?->forget($this->recallerCookie); + $this->devices?->teardown($this->session); $this->session->invalidate(); $this->forgetUser(); $this->viaRemember = false; @@ -198,8 +217,14 @@ public function logoutOtherDevices(string $password): ?Authenticatable return null; } - $this->users->cycleRememberToken($user->getAuthIdentifier()); - $this->queueRecaller($user->getAuthIdentifier()); // reissue for THIS device + // Kill every OTHER device's server-side session (old semantics), then + // rotate the remember token so outstanding recaller cookies die too + // (queueRecaller cycles the token before issuing this device's cookie). + if ($this->devices !== null && $this->request !== null) { + $this->devices->revokeOthers($this->session, $this->request, $user->getAuthIdentifier()); + } + + $this->queueRecaller($user->getAuthIdentifier()); // rotates + reissues for THIS device return $user; } diff --git a/plugins/Auth/Application/Ports/Driver.php b/plugins/Auth/Application/Ports/Driver.php new file mode 100644 index 0000000..9b7a374 --- /dev/null +++ b/plugins/Auth/Application/Ports/Driver.php @@ -0,0 +1,66 @@ +roles !== null) { + $resolved = $this->roles->forUser($userId, $tenant); + $claims['roles'] = $resolved['roles']; + $claims['permissions'] = $resolved['permissions']; + } + $now = time(); $payload = [ 'sub' => $userId, - 'tnt' => $claims['tnt'] ?? $claims['tenant'] ?? '', + 'tnt' => $tenant, 'roles' => array_values($claims['roles'] ?? []), 'permissions' => array_values($claims['permissions'] ?? []), 'iat' => $now, @@ -123,14 +136,14 @@ public function createPersonalAccessToken( ? (new \DateTimeImmutable())->add(new \DateInterval('PT' . $ttlSeconds . 'S')) : null; - $this->tokens->store($id, $userId, $name, $hash, $abilities, $expiresAt); + $this->transactional(fn () => $this->tokens->store($id, $userId, $name, $hash, $abilities, $expiresAt)); return ['id' => $id, 'token' => $plaintext]; } public function revokePersonalAccessToken(string $id): void { - $this->tokens->delete($id); + $this->transactional(fn () => $this->tokens->delete($id)); } public function startSession( @@ -140,6 +153,15 @@ public function startSession( array $permissions = [], string $tenantId = '', ): void { + // RBAC enrichment: when the caller passes no explicit roles/permissions + // and Authorization is loaded, resolve the user's effective grants so the + // session Identity carries them (parity with issueJwt()). + if ($roles === [] && $permissions === [] && $this->roles !== null) { + $resolved = $this->roles->forUser($userId, $tenantId); + $roles = $resolved['roles']; + $permissions = $resolved['permissions']; + } + // Session-fixation defence: rotate the id whenever the privilege level // changes (anonymous → authenticated). Existing flash data is preserved. $session->regenerate(); @@ -176,4 +198,27 @@ public function verifyPassword(string $plain, string $hash): bool { return $this->hasher->check($plain, $hash); } + + /** + * Bracket a unit of work in a transaction on the central auth connection. + * Nesting-aware (TransactionManager), and a straight pass-through when no + * manager was injected (unit tests with in-memory stores). + */ + private function transactional(callable $work): mixed + { + if ($this->transaction === null) { + return $work(); + } + + $this->transaction->begin(); + try { + $result = $work(); + $this->transaction->commit(); + + return $result; + } catch (\Throwable $e) { + $this->transaction->rollback(); + throw $e; + } + } } diff --git a/plugins/Auth/Application/Services/DeviceSessionService.php b/plugins/Auth/Application/Services/DeviceSessionService.php new file mode 100644 index 0000000..0aa63ba --- /dev/null +++ b/plugins/Auth/Application/Services/DeviceSessionService.php @@ -0,0 +1,270 @@ +header($this->fingerprintHeader) ?? ''); + if ($client !== '') { + return hash('sha256', $client); + } + + $ip = (string) ($request->getClientIp() ?? '0.0.0.0'); + $ua = (string) ($request->header('User-Agent') ?? ''); + + return hash('sha256', $ip . '|' . $ua); + } + + // ── Lifecycle ─────────────────────────────────────────────────────────────── + + /** + * Bind the freshly-authenticated session to this device: store the + * fingerprint and open a device-session row. Call right after + * AuthService::startSession(). + */ + public function establish(SessionPort $session, Request $request, string $userId): void + { + $session->put(self::SESSION_FINGERPRINT, $this->fingerprint($request)); + + $opened = $this->open($request, $userId); + $session->put(self::SESSION_DEVICE_TOKEN, $opened['token']); + } + + /** + * Open a device-session row and return its public id + RAW token (the only + * time the raw token exists outside the PHP session). + * + * @return array{id:string,token:string} + */ + public function open(Request $request, string $userId): array + { + $sessionId = bin2hex(random_bytes(16)); + $token = bin2hex(random_bytes(32)); + + $this->transactional(fn () => $this->sessions->insert( + sessionId: $sessionId, + userId: $userId, + tokenHash: hash('sha256', $token), + fingerprint: $this->fingerprint($request), + ip: $request->getClientIp(), + userAgent: $request->header('User-Agent'), + expiresAt: $this->expiry(), + )); + + return ['id' => $sessionId, 'token' => $token]; + } + + /** + * Verify that the session still belongs to this device and is still live + * server-side. True when valid; false ⇒ the caller MUST tear the session down. + * + * Backward-compatible: a session with no stored fingerprint / device token + * (opened before this feature, or a bare startSession()) passes. + */ + public function verify(SessionPort $session, Request $request): bool + { + $stored = (string) $session->get(self::SESSION_FINGERPRINT, ''); + if ($stored !== '' && !hash_equals($stored, $this->fingerprint($request))) { + return false; + } + + $token = (string) $session->get(self::SESSION_DEVICE_TOKEN, ''); + if ($token === '') { + return true; + } + + $row = $this->sessions->findActiveByHash(hash('sha256', $token)); + if ($row === null) { + return false; + } + + $now = new \DateTimeImmutable(); + $expiresAt = new \DateTimeImmutable((string) $row['expires_at']); + if ($expiresAt <= $now) { + return false; + } + + // Rolling refresh: once inside the refresh window, slide the expiry a + // full TTL forward. Otherwise just stamp last-seen (rate-limited). + $refreshFrom = $expiresAt->sub(new \DateInterval('P' . max(1, $this->refreshDays) . 'D')); + if ($now >= $refreshFrom) { + $this->sessions->touch((string) $row['session_id'], $this->expiry()); + } elseif ($this->lastSeenIsStale($row['last_seen_at'] ?? null, $now)) { + $this->sessions->touch((string) $row['session_id']); + } + + return true; + } + + /** Revoke this device's server-side session (logout). */ + public function teardown(SessionPort $session): void + { + $token = (string) $session->get(self::SESSION_DEVICE_TOKEN, ''); + if ($token !== '') { + $this->transactional(fn () => $this->sessions->revokeByHash(hash('sha256', $token))); + } + + $session->forget(self::SESSION_DEVICE_TOKEN); + $session->forget(self::SESSION_FINGERPRINT); + } + + /** + * Revoke every OTHER device session for the user, keeping the current + * device's row alive (old logoutOtherDevices semantics). Returns the number + * of sessions revoked. + */ + public function revokeOthers(SessionPort $session, Request $request, string $userId): int + { + $token = (string) $session->get(self::SESSION_DEVICE_TOKEN, ''); + $currentId = null; + + if ($token !== '') { + $row = $this->sessions->findActiveByHash(hash('sha256', $token)); + $currentId = $row !== null ? (string) $row['session_id'] : null; + } + + // One transaction: the sweep and the replacement row commit together, + // so a failure cannot leave the user with every device signed out AND + // no registered session (the shared manager nests establish → open). + return $this->transactional(function () use ($session, $request, $userId, $currentId): int { + $revoked = $this->sessions->revokeAllForUser($userId, $currentId); + + // No live row for this device (pre-feature session) — open one so + // the user keeps a registered session after the sweep. + if ($currentId === null) { + $this->establish($session, $request, $userId); + } + + return $revoked; + }); + } + + // ── Device listing / targeted revocation ──────────────────────────────────── + + /** + * Active sessions for a user, flagging the caller's own device. + * + * @return list> + */ + public function listDevices(string $userId, ?SessionPort $session = null): array + { + $currentId = null; + $token = $session !== null ? (string) $session->get(self::SESSION_DEVICE_TOKEN, '') : ''; + if ($token !== '') { + $row = $this->sessions->findActiveByHash(hash('sha256', $token)); + $currentId = $row !== null ? (string) $row['session_id'] : null; + } + + return array_map(static fn (array $row): array => [ + 'id' => $row['session_id'], + 'ip' => $row['ip'], + 'userAgent' => $row['user_agent'], + 'lastSeen' => $row['last_seen_at'], + 'createdAt' => $row['created_at'], + 'expiresAt' => $row['expires_at'], + 'current' => $row['session_id'] === $currentId, + ], $this->sessions->listActiveForUser($userId)); + } + + /** Revoke one of the user's sessions by public id. True when it existed. */ + public function revokeById(string $userId, string $sessionId): bool + { + return $this->transactional(fn (): bool => $this->sessions->revokeForUser($userId, $sessionId)); + } + + // ── Internals ─────────────────────────────────────────────────────────────── + + /** + * Bracket a unit of work in a transaction on the central auth connection. + * Nesting-aware (TransactionManager), and a straight pass-through when no + * manager was injected (unit tests with in-memory stores). + */ + private function transactional(callable $work): mixed + { + if ($this->transaction === null) { + return $work(); + } + + $this->transaction->begin(); + try { + $result = $work(); + $this->transaction->commit(); + + return $result; + } catch (\Throwable $e) { + $this->transaction->rollback(); + throw $e; + } + } + + private function expiry(): \DateTimeImmutable + { + return (new \DateTimeImmutable())->add(new \DateInterval('P' . max(1, $this->ttlDays) . 'D')); + } + + private function lastSeenIsStale(mixed $lastSeenAt, \DateTimeImmutable $now): bool + { + if (!is_string($lastSeenAt) || $lastSeenAt === '') { + return true; + } + + try { + $seen = new \DateTimeImmutable($lastSeenAt); + } catch (\Exception) { + return true; + } + + return ($now->getTimestamp() - $seen->getTimestamp()) >= self::TOUCH_INTERVAL_SECONDS; + } +} diff --git a/plugins/Auth/Application/Services/MobileAuthService.php b/plugins/Auth/Application/Services/MobileAuthService.php new file mode 100644 index 0000000..916b8d0 --- /dev/null +++ b/plugins/Auth/Application/Services/MobileAuthService.php @@ -0,0 +1,126 @@ + $oauthParams client_id, redirect_uri, scope, + * state, code_challenge, code_challenge_method + * @return array{code:string,state:string} + * @throws \Plugins\OAuth2\Domain\Exceptions\OAuthException invalid client/redirect/scope/PKCE + * @throws ServiceException when the OAuth2 module is not loaded for this route + */ + public function issueCode(string $userId, array $oauthParams): array + { + if ($this->oauthFlow === null) { + throw new ServiceException( + 'auth.mobile.oauth_unavailable', + layer: 'service.auth.mobile', + context: ['hint' => 'The oauth.server module must be required by this route.'], + ); + } + + $issued = $this->oauthFlow->issueCodeFor($oauthParams, $userId); + + return ['code' => $issued['code'], 'state' => $issued['state']]; + } + + // ── Registration (old register→code flow) ─────────────────────────────────── + + /** + * Create the account and return the fresh UserDTO. Auto-verifies the email + * (old mobile activate-on-register behaviour) unless disabled — the + * plaintext verification token never leaves the server either way. + */ + public function register(RegisterUserDTO $dto): UserDTO + { + $verificationToken = $this->users->registerPublic($dto); + + if ($this->autoVerify) { + // Old flow parity: mobile users are activated immediately so the + // code exchange isn't gated behind an inbox round-trip. Non-fatal — + // a failure just leaves the account pending verification. + try { + $this->users->verifyEmailByToken($verificationToken); + } catch (\Throwable) { + } + } + + $user = $this->users->findByIdentifier($dto->email->value()); + if ($user === null) { + throw new ServiceException('auth.mobile.register.lookup_failed', layer: 'service.auth.mobile'); + } + + return $user; + } + + // ── Logout (JTI blocklist) ────────────────────────────────────────────────── + + /** + * Blocklist the presented access token's JTI for its remaining lifetime. + * The token was already cryptographically verified by the `auth` filter — + * this only READS the payload to find jti/exp; a malformed token is a no-op. + */ + public function revokeAccessToken(?string $bearer): void + { + if ($bearer === null || $bearer === '') { + return; + } + + $parts = explode('.', $bearer); + if (\count($parts) !== 3) { + return; + } + + $padded = strtr($parts[1], '-_', '+/') . str_repeat('=', (4 - \strlen($parts[1]) % 4) % 4); + $decoded = base64_decode($padded, strict: true); + $payload = $decoded !== false ? json_decode($decoded, true) : null; + + if (!\is_array($payload) || !\is_string($payload['jti'] ?? null)) { + return; + } + + $remaining = (int) ($payload['exp'] ?? 0) - time(); + if ($remaining > 0) { + $this->auth->revokeJwt($payload['jti'], $remaining); + } + } +} diff --git a/plugins/Auth/Application/Services/RefreshTokenService.php b/plugins/Auth/Application/Services/RefreshTokenService.php index 006ac07..83fc03f 100644 --- a/plugins/Auth/Application/Services/RefreshTokenService.php +++ b/plugins/Auth/Application/Services/RefreshTokenService.php @@ -4,6 +4,7 @@ namespace Plugins\Auth\Application\Services; +use AlfacodeTeam\PhpServicePlatform\Kernel\Database\TransactionManager; use Plugins\Auth\API\Contracts\AuthServiceContract; use Plugins\Auth\API\Contracts\RefreshTokenServiceContract; use Plugins\Auth\API\DTOs\RefreshRotation; @@ -34,6 +35,7 @@ public function __construct( private readonly AuthServiceContract $auth, private readonly int $refreshTtl = 2592000, // 30 days private readonly int $accessTtl = 900, // 15 minutes + private readonly ?TransactionManager $transaction = null, // central-connection tx ) {} public function issue( @@ -47,7 +49,8 @@ public function issue( $expiresAt = $this->expiry($this->refreshTtl); // A freshly-issued token is the ROOT of its own rotation family. - $this->tokens->store($tokenId, $tokenId, $userId, Token::hash($rawToken), $tenantId, $device, $ip, $expiresAt); + $this->transactional(fn () => + $this->tokens->store($tokenId, $tokenId, $userId, Token::hash($rawToken), $tenantId, $device, $ip, $expiresAt)); return new RefreshTokenIssued($tokenId, $rawToken, $expiresAt->format(\DateTimeInterface::RFC3339)); } @@ -60,28 +63,41 @@ public function rotate(string $rawToken, ?string $ip = null): RefreshRotation } // Reuse detection: a known-but-already-revoked token is a replay of a - // token that was rotated away (or stolen). Burn the whole family. + // token that was rotated away (or stolen). Burn the whole family. The + // burn runs in its OWN transaction, committed BEFORE the throw — it must + // persist, never be rolled back with the failed rotation. if ($record->revoked) { - $this->tokens->revokeFamily($record->familyId); + $this->transactional(fn () => $this->tokens->revokeFamily($record->familyId)); throw InvalidRefreshTokenException::reuseDetected(); } - // One-time use, atomically: only the request that wins the conditional - // revoke may proceed. A concurrent rotation loses the race (0 rows) and - // is treated as reuse — burn the family. - if (!$this->tokens->revokeIfActive($record->tokenId)) { - $this->tokens->revokeFamily($record->familyId); + $newRawToken = Token::random(); + $newTokenId = Token::ulid(); + $refreshExp = $this->expiry($this->refreshTtl); + + // One-time-use rotation is ATOMIC: the conditional revoke of the + // presented token and the insert of its replacement commit or fail + // together — a crash between them can no longer strand the user with + // no valid refresh token. Only the request that wins the conditional + // revoke may proceed; a concurrent rotation loses the race (0 rows). + $won = $this->transactional(function () use ($record, $newTokenId, $newRawToken, $refreshExp, $ip): bool { + if (!$this->tokens->revokeIfActive($record->tokenId)) { + return false; + } + + $this->tokens->store($newTokenId, $record->familyId, $record->userId, Token::hash($newRawToken), $record->tenantId, null, $ip, $refreshExp); + + return true; + }); + + // Lost the race — treat as reuse: burn the family (own committed tx). + if (!$won) { + $this->transactional(fn () => $this->tokens->revokeFamily($record->familyId)); throw InvalidRefreshTokenException::reuseDetected(); } $tenantId = $record->tenantId; - // Issue the replacement refresh token (same scope, same family). - $newRawToken = Token::random(); - $newTokenId = Token::ulid(); - $refreshExp = $this->expiry($this->refreshTtl); - $this->tokens->store($newTokenId, $record->familyId, $record->userId, Token::hash($newRawToken), $tenantId, null, $ip, $refreshExp); - // Mint the paired access token (tnt is a passthrough hint, not re-verified). $accessToken = $this->auth->issueJwt( $record->userId, @@ -104,16 +120,39 @@ public function revoke(string $rawToken): void if ($record === null) { return; } - $this->tokens->revoke($record->tokenId); + $this->transactional(fn () => $this->tokens->revoke($record->tokenId)); } public function revokeAllForUser(string $userId): int { - return $this->tokens->revokeAllForUser($userId); + return $this->transactional(fn (): int => $this->tokens->revokeAllForUser($userId)); } private function expiry(int $ttlSeconds): \DateTimeImmutable { return (new \DateTimeImmutable())->add(new \DateInterval('PT' . max(60, $ttlSeconds) . 'S')); } + + /** + * Bracket a unit of work in a transaction on the central auth connection. + * Nesting-aware (TransactionManager), and a straight pass-through when no + * manager was injected (unit tests with in-memory stores). + */ + private function transactional(callable $work): mixed + { + if ($this->transaction === null) { + return $work(); + } + + $this->transaction->begin(); + try { + $result = $work(); + $this->transaction->commit(); + + return $result; + } catch (\Throwable $e) { + $this->transaction->rollback(); + throw $e; + } + } } diff --git a/plugins/Auth/Infrastructure/Http/Controllers/MobileAuthController.php b/plugins/Auth/Infrastructure/Http/Controllers/MobileAuthController.php new file mode 100644 index 0000000..eb86a30 --- /dev/null +++ b/plugins/Auth/Infrastructure/Http/Controllers/MobileAuthController.php @@ -0,0 +1,139 @@ +authManager()->issueTokenPair()` / + * ->issueToken()), the exact parity of the old `AuthManager::issueToken('mobile', + * …)`. Nothing here reaches into AuthService/RefreshTokenService directly. + * + * POST /auth/mobile/login { email|identifier, password [, PKCE params] } + * PKCE (client_id set) → 200 { code, state } + * legacy (no client_id) → 200 { user, tokens } + * POST /auth/mobile/register registration fields + optional PKCE params + * → 201 { code, state } | 201 { user, tokens } + * POST /auth/mobile/logout (Bearer) → 200 {} — JTI blocklisted. + * + * Refresh rotation stays at POST /auth/refresh (AuthTokenController). + */ +final class MobileAuthController extends ApiController +{ + use InteractsWithAuthManager; + + public function __construct( + private readonly UserServiceContract $users, + private readonly MobileAuthService $mobile, + ) { + } + + public function login(): Response + { + $request = $this->resolveRequest(); + $identifier = trim((string) ($request->input('identifier') ?? $request->input('email'))); + $password = (string) $request->input('password'); + + if ($identifier === '' || $password === '') { + return $this->unprocessable([ + 'identifier' => $identifier === '' ? 'An email or username is required.' : '', + 'password' => $password === '' ? 'A password is required.' : '', + ]); + } + + $user = $this->users->verifyCredentials($identifier, $password); + if ($user === null) { + return Response::unauthorized('Invalid email/username or password.'); + } + + if ($this->wantsPkce($request)) { + return $this->issueCodeResponse($request, $user->id); + } + + return $this->ok([ + 'user' => $user->toArray(), + 'tokens' => $this->authManager()->issueTokenPair($user->id, device: $request->header('User-Agent'), ip: $request->ip()), + ]); + } + + public function register(): Response + { + $request = $this->resolveRequest(); + + // Mobile clients register with email only — synthesize the internal + // username from the email local-part (old flow) when none is sent. + if (trim((string) $request->input('username', '')) === '') { + $request = $request->merge(['username' => $this->usernameFromEmail((string) $request->input('email', ''))]); + } + + $user = $this->mobile->register(RegisterUserDTO::fromRequest($request)); // 422 on bad input + + if ($this->wantsPkce($request)) { + return $this->issueCodeResponse($request, $user->id, status: 201); + } + + return $this->created([ + 'user' => $user->toArray(), + 'tokens' => $this->authManager()->issueTokenPair($user->id, device: $request->header('User-Agent'), ip: $request->ip()), + ]); + } + + public function logout(): Response + { + $this->mobile->revokeAccessToken($this->resolveRequest()->bearerToken()); + + return $this->ok([]); + } + + // ── Internals ─────────────────────────────────────────────────────────────── + + private function wantsPkce(Request $request): bool + { + return trim((string) $request->input('client_id', '')) !== ''; + } + + private function issueCodeResponse(Request $request, string $userId, int $status = 200): Response + { + try { + $issued = $this->mobile->issueCode($userId, [ + 'client_id' => (string) $request->input('client_id', ''), + 'redirect_uri' => (string) $request->input('redirect_uri', ''), + 'scope' => (string) $request->input('scope', ''), + 'state' => (string) $request->input('state', ''), + 'code_challenge' => (string) $request->input('code_challenge', ''), + 'code_challenge_method' => (string) $request->input('code_challenge_method', ''), + ]); + } catch (OAuthException $e) { + return Response::json( + ['error' => ['code' => $e->error, 'message' => $e->getMessage()]], + $e->status, + ); + } + + return Response::json($issued, $status); + } + + /** Old flow: email local-part + 4 random hex chars — internal, never exposed. */ + private function usernameFromEmail(string $email): string + { + $local = (string) preg_replace('/[^A-Za-z0-9._-]/', '', explode('@', $email)[0] ?? ''); + if (\strlen($local) < 2) { + $local = 'user'; + } + + return strtolower(substr($local, 0, 42)) . '_' . substr(bin2hex(random_bytes(2)), 0, 4); + } +} diff --git a/plugins/Auth/Infrastructure/Http/Controllers/PasswordResetController.php b/plugins/Auth/Infrastructure/Http/Controllers/PasswordResetController.php new file mode 100644 index 0000000..cc476d9 --- /dev/null +++ b/plugins/Auth/Infrastructure/Http/Controllers/PasswordResetController.php @@ -0,0 +1,109 @@ +resolveRequest()->input('email'))); + if ($email === '') { + return $this->unprocessable(['email' => 'An email address is required.']); + } + + $sent = $this->broker->sendOtp($email); + + if ($sent !== null && $this->mail !== null) { + try { + $this->mail->send( + $sent['email'], + 'Your password reset code', + 'auth::password-otp', + ['otp' => $sent['otp'], 'expiresMinutes' => 10], + ); + } catch (\Throwable) { + // Non-fatal — never leak delivery problems to the caller. + } + } + + // Always 200 — never reveals whether an account exists. + return $this->ok(['message' => self::GENERIC_FORGOT_MESSAGE]); + } + + public function verifyOtp(): Response + { + $request = $this->resolveRequest(); + $email = mb_strtolower(trim((string) $request->input('email'))); + $otp = trim((string) $request->input('otp')); + + if ($email === '' || !preg_match('/^\d{6}$/', $otp)) { + return $this->unprocessable([ + 'email' => $email === '' ? 'An email address is required.' : '', + 'otp' => 'The code must be exactly 6 digits.', + ]); + } + + $token = $this->broker->verifyOtp($email, $otp); + if ($token === null) { + return Response::json(['error' => [ + 'code' => 'auth.password.otp_invalid', + 'message' => "That code doesn't match or has expired. Codes are valid for 10 minutes — request a fresh one.", + ]], 400); + } + + return $this->ok(['resetToken' => $token]); + } + + public function reset(): Response + { + $request = $this->resolveRequest(); + $email = mb_strtolower(trim((string) $request->input('email'))); + $token = trim((string) $request->input('token')); + $password = (string) $request->input('password'); + + if ($email === '' || $token === '' || \strlen($password) < 8) { + return $this->unprocessable([ + 'email' => $email === '' ? 'An email address is required.' : '', + 'token' => $token === '' ? 'Your reset session is missing — request a new code.' : '', + 'password' => \strlen($password) < 8 ? 'Password must be at least 8 characters.' : '', + ]); + } + + if ($this->broker->reset($email, $token, $password) !== PasswordBroker::PASSWORD_RESET) { + return Response::json(['error' => [ + 'code' => 'auth.password.reset_invalid', + 'message' => 'This reset session has already been used or has expired. Please start again.', + ]], 400); + } + + return $this->ok(['message' => 'Your password has been updated. You can now sign in.']); + } +} diff --git a/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php b/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php index 70dda51..47b1195 100644 --- a/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php +++ b/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php @@ -5,46 +5,47 @@ namespace Plugins\Auth\Infrastructure\Http\Controllers; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort; -use Plugins\Auth\API\Contracts\AuthServiceContract; -use Plugins\Auth\Domain\ValueObjects\Recaller; -use Plugins\Auth\Infrastructure\Http\Stages\SessionAuthStage; -use Plugins\Cookie\Infrastructure\CookieJar; -use Plugins\User\API\Contracts\UserServiceContract; +use Plugins\Auth\Application\Ports\Authenticatable; +use Plugins\Auth\Application\Services\DeviceSessionService; use Project\Http\Controllers\ApiController; +use Project\Http\Controllers\Concerns\InteractsWithAuthManager; /** * Stateful (session) login/logout for browser + AJAX clients. * - * Flow: - * POST /auth/login { identifier, password } → verify credentials, open a - * session login, return the user (200) or 401 on failure. - * POST /auth/logout → tear the session down (204). - * GET /auth/me → current session identity. + * Everything runs through the AuthManager guard — `$this->auth('web')` — exactly + * the old `$auth->guard('web')->attempt()/logout()` ergonomic. The 'web' guard's + * driver owns credential verification, the session write, remember-me, and the + * device-session registry; this controller only translates request → guard call + * → Response. Incoming-token verification (for token/JWT callers) still happens + * in the SecurityGateway before modules load — that is the one thing the guard + * cannot own under GDA. * - * Credentials are verified by the User module (timing-safe + lockout); this - * controller only turns a verified user into a session via AuthService. The - * session Identity is rebuilt on later requests by SessionAuthStage, so the - * `auth` route filter protects both token and session callers uniformly. + * POST /auth/login { identifier|email, password, remember? } + * POST /auth/logout → 204 + * GET /auth/me → current identity + * GET /auth/sessions → active device sessions + * DELETE /auth/sessions/{id} → revoke one device + * POST /auth/logout-other-devices { password } → revoke all OTHER devices * - * CSRF: these are session/cookie endpoints (NOT under /api), so the kernel's - * CsrfTokenLayer guards the POSTs — the client sends the HMAC token via the - * `X-CSRF-Token` header (AJAX) or the `_csrf_token` field (form). + * CSRF: session/cookie endpoints (NOT under /api) are guarded by the kernel's + * CsrfTokenLayer — send the token via `X-CSRF-Token` (AJAX) or `_csrf_token`. */ final class SessionAuthController extends ApiController { + use InteractsWithAuthManager; + public function __construct( - private readonly AuthServiceContract $auth, - private readonly UserServiceContract $users, - private readonly SessionPort $session, - private readonly ?CookieJar $cookies = null, + // Only the device-management endpoints (list / revoke-by-id) touch the + // registry directly; the login/logout flow goes through the guard. + private readonly ?DeviceSessionService $devices = null, ) { } public function login(): Response { $request = $this->resolveRequest(); - $identifier = trim((string) $request->input('identifier')); + $identifier = trim((string) ($request->input('identifier') ?? $request->input('email'))); $password = (string) $request->input('password'); if ($identifier === '' || $password === '') { @@ -54,45 +55,33 @@ public function login(): Response ]); } - $user = $this->users->verifyCredentials($identifier, $password); - if ($user === null) { + $guard = $this->auth('web'); + + + // The 'web' guard's driver verifies credentials, opens the session, + // binds the device fingerprint + auth_sessions row, and (when asked) + // queues the remember-me recaller — all internally. + if (!$guard->attempt($this->credentials($identifier, $password), $request->boolean('remember'))) { // Uniform message — never reveals whether the account exists or is locked. return Response::unauthorized('Invalid credentials.'); } - $this->auth->startSession($this->session, $user->id); - - // "Remember me" — issue an encrypted recaller cookie so the session can - // be re-established after it expires (validated by SessionAuthStage). - if ($this->cookies !== null && $request->boolean('remember')) { - $token = $this->users->cycleRememberToken($user->id); - $this->cookies->queue( - SessionAuthStage::RECALLER_COOKIE, - Recaller::make($user->id, $token)->value(), - maxAge: SessionAuthStage::RECALLER_TTL, - ); - } - - return $this->ok(['user' => $user->toArray()]); + return $this->ok(['user' => $this->shape($guard->user())]); } public function logout(): Response { - // Kill any outstanding recaller: clear the stored token so existing - // cookies stop authenticating, then expire the cookie itself. - $userId = $this->identity()->userId; - if ($userId !== '') { - $this->users->clearRememberToken($userId); - } - $this->cookies?->forget(SessionAuthStage::RECALLER_COOKIE); - - $this->auth->endSession($this->session); + // Guard tears down the session, revokes this device's registry row, and + // clears the remember-me token + cookie. + $this->auth('web')->logout(); return $this->noContent(); } public function me(): Response { + // Reflects HOWEVER the request is authenticated (session OR a verified + // token attached by the SecurityGateway), so read the request Identity. $identity = $this->identity(); if ($identity->isGuest()) { return Response::unauthorized('Not authenticated.'); @@ -106,4 +95,86 @@ public function me(): Response 'via' => $identity->tokenType, ]); } + + // ── Device sessions ("see & sign out my devices") ─────────────────────────── + + public function sessions(): Response + { + if ($this->devices === null) { + return $this->ok(['sessions' => []]); + } + + return $this->ok([ + 'sessions' => $this->devices->listDevices($this->identity()->userId, $this->guardSession()), + ]); + } + + public function revokeSession(string $id): Response + { + if ($this->devices === null || !$this->devices->revokeById($this->identity()->userId, $id)) { + return $this->notFound('No such session.'); + } + + return $this->noContent(); + } + + public function logoutOtherDevices(): Response + { + $password = (string) $this->resolveRequest()->input('password'); + if ($password === '') { + return $this->unprocessable(['password' => 'Your current password is required.']); + } + + // Guard re-verifies the password, revokes every OTHER device's session, + // and rotates the remember token (reissuing this device's cookie). + $user = $this->auth('web')->logoutOtherDevices($password); + if ($user === null) { + return Response::unauthorized('Password confirmation failed.'); + } + + return $this->ok(['message' => 'Signed out of all other devices.']); + } + + // ── Internals ─────────────────────────────────────────────────────────────── + + /** + * Credential map: the identifier is offered under every lookup field the + * ModelUserProvider tries (identifier/email/username), so a single input + * works whether the user typed an email or a username. + * + * @return array + */ + private function credentials(string $identifier, string $password): array + { + return [ + 'identifier' => $identifier, + 'email' => $identifier, + 'username' => $identifier, + 'password' => $password, + ]; + } + + /** @return array */ + private function shape(?Authenticatable $user): array + { + if ($user === null) { + return []; + } + + return [ + 'id' => $user->getAuthIdentifier(), + 'email' => $user->getEmail(), + 'username' => $user->getUsername(), + ]; + } + + /** The active session store, for flagging the caller's own device in listDevices(). */ + private function guardSession(): ?\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort + { + $container = $this->resolveRequest()->container(); + + return $container !== null && $container->has(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort::class) + ? $container->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort::class) + : null; + } } diff --git a/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php b/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php index 5cd9290..f12547a 100644 --- a/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php +++ b/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php @@ -11,6 +11,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; use Plugins\Auth\API\Contracts\AuthServiceContract; use Plugins\Auth\Application\Services\AuthService; +use Plugins\Auth\Application\Services\DeviceSessionService; use Plugins\Auth\Domain\ValueObjects\Recaller; use Plugins\Cookie\Infrastructure\CookieJar; use Plugins\User\API\Contracts\UserServiceContract; @@ -71,6 +72,19 @@ public function handle(Request $request, callable $next): Response return $next($request); } + // Fingerprint + device-session validation (old __DEV__ semantics): a + // request that can't reproduce the login fingerprint, or whose server- + // side session row was revoked/expired, loses the session outright. + if ($container->has(DeviceSessionService::class)) { + $devices = $container->make(DeviceSessionService::class); + if ($devices instanceof DeviceSessionService && !$devices->verify($session, $request)) { + $devices->teardown($session); + $session->invalidate(); + + return $next($request); // continue as guest + } + } + $identity = new Identity( userId: $userId, tenantId: (string) $session->get(AuthService::SESSION_TENANT, ''), diff --git a/plugins/Auth/Infrastructure/Persistence/DeviceSessionRepository.php b/plugins/Auth/Infrastructure/Persistence/DeviceSessionRepository.php new file mode 100644 index 0000000..0558080 --- /dev/null +++ b/plugins/Auth/Infrastructure/Persistence/DeviceSessionRepository.php @@ -0,0 +1,189 @@ +db->execute( + "INSERT INTO {$this->table} + (session_id, user_id, token_hash, fingerprint, ip, user_agent, last_seen_at, expires_at, created_at) + VALUES (:session_id, :user_id, :token_hash, :fingerprint, :ip, :user_agent, :last_seen_at, :expires_at, :created_at)", + [ + 'session_id' => $sessionId, + 'user_id' => $userId, + 'token_hash' => $tokenHash, + 'fingerprint' => $fingerprint, + 'ip' => $ip !== null ? mb_substr($ip, 0, 45) : null, + 'user_agent' => $userAgent !== null ? mb_substr($userAgent, 0, 191) : null, + 'last_seen_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'expires_at' => $expiresAt->format('Y-m-d H:i:s'), + 'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + ] + ); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to open device session', layer: 'repository.auth', previous: $e); + } + } + + /** + * Look up an UNREVOKED session by its token hash. Expiry is enforced in PHP + * by the caller (driver-portable — no NOW() dialect branching). + * + * @return array{session_id:string,user_id:string,fingerprint:?string,last_seen_at:?string,expires_at:string}|null + */ + public function findActiveByHash(string $tokenHash): ?array + { + try { + return $this->db->queryOne( + "SELECT session_id, user_id, fingerprint, last_seen_at, expires_at + FROM {$this->table} WHERE token_hash = :hash AND revoked_at IS NULL", + ['hash' => $tokenHash] + ); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to look up device session', layer: 'repository.auth', previous: $e); + } + } + + /** + * Stamp last-seen and (for rolling refresh) push the expiry forward. + * Best-effort — observability + sliding lifetime, not an auth gate. + */ + public function touch(string $sessionId, ?\DateTimeImmutable $newExpiresAt = null): void + { + $params = [ + 'now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'id' => $sessionId, + ]; + $set = 'last_seen_at = :now'; + + if ($newExpiresAt !== null) { + $set .= ', expires_at = :expires_at'; + $params['expires_at'] = $newExpiresAt->format('Y-m-d H:i:s'); + } + + try { + $this->db->execute("UPDATE {$this->table} SET {$set} WHERE session_id = :id", $params); + } catch (\PDOException) { + // Non-fatal. + } + } + + public function revokeByHash(string $tokenHash): void + { + try { + $this->db->execute( + "UPDATE {$this->table} SET revoked_at = :now WHERE token_hash = :hash AND revoked_at IS NULL", + ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'hash' => $tokenHash] + ); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to revoke device session', layer: 'repository.auth', previous: $e); + } + } + + /** Revoke one of a user's sessions by its public id. True when a row changed. */ + public function revokeForUser(string $userId, string $sessionId): bool + { + try { + return $this->db->execute( + "UPDATE {$this->table} SET revoked_at = :now + WHERE user_id = :user_id AND session_id = :id AND revoked_at IS NULL", + [ + 'now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'user_id' => $userId, + 'id' => $sessionId, + ] + ) > 0; + } catch (\PDOException $e) { + throw new RepositoryException('Failed to revoke device session', layer: 'repository.auth', previous: $e); + } + } + + /** Revoke every active session for a user, optionally sparing one (the current device). */ + public function revokeAllForUser(string $userId, ?string $exceptSessionId = null): int + { + $sql = "UPDATE {$this->table} SET revoked_at = :now WHERE user_id = :user_id AND revoked_at IS NULL"; + $params = [ + 'now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + 'user_id' => $userId, + ]; + + if ($exceptSessionId !== null) { + $sql .= ' AND session_id <> :except'; + $params['except'] = $exceptSessionId; + } + + try { + return $this->db->execute($sql, $params); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to revoke device sessions', layer: 'repository.auth', previous: $e); + } + } + + /** + * All active (unrevoked, unexpired) sessions for a user, newest first. + * Never returns the token hash. + * + * @return list + */ + public function listActiveForUser(string $userId): array + { + try { + $rows = $this->db->query( + "SELECT session_id, ip, user_agent, last_seen_at, created_at, expires_at + FROM {$this->table} + WHERE user_id = :user_id AND revoked_at IS NULL AND expires_at > :cutoff + ORDER BY created_at DESC", + [ + 'user_id' => $userId, + 'cutoff' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + ] + ); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to list device sessions', layer: 'repository.auth', previous: $e); + } + + return array_values($rows); + } + + /** Delete expired/revoked rows older than the cutoff (maintenance). */ + public function deleteStale(?\DateTimeImmutable $now = null): int + { + $cutoff = ($now ?? new \DateTimeImmutable())->format('Y-m-d H:i:s'); + + try { + return $this->db->execute( + "DELETE FROM {$this->table} WHERE expires_at <= :cutoff OR revoked_at IS NOT NULL", + ['cutoff' => $cutoff] + ); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to prune device sessions', layer: 'repository.auth', previous: $e); + } + } +} diff --git a/plugins/Auth/Provider.php b/plugins/Auth/Provider.php index c8956b3..0e77d15 100644 --- a/plugins/Auth/Provider.php +++ b/plugins/Auth/Provider.php @@ -61,6 +61,18 @@ public function exposes(): array public function register(ModuleContainer $container): void { + // ONE nesting-aware transaction manager for ALL Auth writes. Every Auth + // repository is pinned to the CENTRAL connection, so transactions must + // bracket that same connection — the kernel's request TransactionManager + // wraps the (possibly tenant-rebound) DatabasePort and would open the + // transaction on the wrong database. Shared (singleton) so composed + // flows (revokeOthers → establish) nest instead of double-beginning. + $container->singleton('auth.transaction', static fn(ModuleContainer $c) => + new \AlfacodeTeam\PhpServicePlatform\Kernel\Database\TransactionManager( + $c->make(DatabaseConnectionManagerContract::class)->default(), + ) + ); + $container->bindInternal(PersonalAccessTokenRepository::class, static fn(ModuleContainer $c) => new PersonalAccessTokenRepository( // Central connection — tokens belong to the control plane, not a @@ -71,6 +83,39 @@ public function register(ModuleContainer $container): void ) ); + // Device-session registry (central — auth_sessions is control-plane). + $container->bindInternal(\Plugins\Auth\Infrastructure\Persistence\DeviceSessionRepository::class, + static fn(ModuleContainer $c) => + new \Plugins\Auth\Infrastructure\Persistence\DeviceSessionRepository( + $c->make(DatabaseConnectionManagerContract::class)->default(), + ) + ); + + // Fingerprint + server-side session validation. Public bind (not exposed + // cross-module) so SessionAuthStage can resolve it from the request + // container on every stateful request. + $container->bind(\Plugins\Auth\Application\Services\DeviceSessionService::class, + static fn(ModuleContainer $c) => + new \Plugins\Auth\Application\Services\DeviceSessionService( + sessions: $c->make(\Plugins\Auth\Infrastructure\Persistence\DeviceSessionRepository::class), + ttlDays: (int) (\auth_config('session.ttl_days') ?? 30), + refreshDays: (int) (\auth_config('session.refresh_days') ?? 7), + fingerprintHeader: (string) (\auth_config('session.client_fingerprint_header') ?? 'X-Client-Fingerprint'), + transaction: $c->make('auth.transaction'), + ) + ); + + // RBAC bridge — resolves a user's roles/permissions from the + // Authorization plugin's policy store when it is loaded for the request; + // degrades to empty lists otherwise (optional dependency). + $container->bindInternal(\Plugins\Auth\Application\Auth\RoleResolver::class, static fn(ModuleContainer $c) => + new \Plugins\Auth\Application\Auth\RoleResolver( + $c->has(\Plugins\Authorization\API\Contracts\AuthorizationServiceContract::class) + ? $c->make(\Plugins\Authorization\API\Contracts\AuthorizationServiceContract::class) + : null, + ) + ); + $container->bind(AuthServiceContract::class, static fn(ModuleContainer $c) => new AuthService( tokens: $c->make(PersonalAccessTokenRepository::class), @@ -82,21 +127,19 @@ public function register(ModuleContainer $container): void cache: $c->has(CachePort::class) ? $c->make(CachePort::class) : null, jwtPrivateKey: self::readKey(env('JWT_PRIVATE_KEY'), env('JWT_PRIVATE_KEY_FILE')), jwtKid: env('JWT_KID') ?: null, + roles: $c->make(\Plugins\Auth\Application\Auth\RoleResolver::class), + transaction: $c->make('auth.transaction'), ) ); // Session login/logout controller for web + AJAX. Credentials verified by // the User module; SessionPort (essential) carries the stateful session. + // Session login/logout drives the AuthManager 'web' guard; the + // controller only needs the device registry for the list/revoke-by-id + // endpoints (the login flow itself goes through the guard). $container->bindInternal(SessionAuthController::class, static fn(ModuleContainer $c) => new SessionAuthController( - $c->make(AuthServiceContract::class), - $c->make(UserServiceContract::class), - $c->make(SessionPort::class), - // Cookie is essential, but resolve defensively so Auth still - // boots if it is ever unwired — remember-me just no-ops. - $c->has(\Plugins\Cookie\Infrastructure\CookieJar::class) - ? $c->make(\Plugins\Cookie\Infrastructure\CookieJar::class) - : null, + $c->make(\Plugins\Auth\Application\Services\DeviceSessionService::class), ) ); @@ -118,10 +161,11 @@ public function register(ModuleContainer $container): void // Refresh-token service (revocable long-lived first-party sessions). $container->bind(\Plugins\Auth\API\Contracts\RefreshTokenServiceContract::class, static fn(ModuleContainer $c) => new \Plugins\Auth\Application\Services\RefreshTokenService( - tokens: $c->make(\Plugins\Auth\Application\Ports\RefreshTokenStore::class), - auth: $c->make(AuthServiceContract::class), - refreshTtl: (int) (env('AUTH_REFRESH_TTL') ?: 2592000), - accessTtl: (int) (env('AUTH_REFRESH_ACCESS_TTL') ?: 900), + tokens: $c->make(\Plugins\Auth\Application\Ports\RefreshTokenStore::class), + auth: $c->make(AuthServiceContract::class), + refreshTtl: (int) (env('AUTH_REFRESH_TTL') ?: 2592000), + accessTtl: (int) (env('AUTH_REFRESH_ACCESS_TTL') ?: 900), + transaction: $c->make('auth.transaction'), ) ); @@ -140,14 +184,39 @@ public function register(ModuleContainer $container): void ) ); + // Mobile auth flow (old __DEV__ /v1/auth/*). PUBLIC binds so a project + // route override (adding "requires": ["auth.identity","oauth.server"]) + // can resolve them — that override is how PKCE mode is enabled; without + // it the OAuth2 module isn't in the graph and PKCE returns a clear 4xx. + $container->bind(\Plugins\Auth\Application\Services\MobileAuthService::class, static fn(ModuleContainer $c) => + new \Plugins\Auth\Application\Services\MobileAuthService( + users: $c->make(UserServiceContract::class), + auth: $c->make(AuthServiceContract::class), + oauthFlow: $c->has(\Plugins\OAuth2\Application\Ports\AuthorizationFlow::class) + ? $c->make(\Plugins\OAuth2\Application\Ports\AuthorizationFlow::class) + : null, + autoVerify: !\in_array(strtolower((string) (env('AUTH_MOBILE_AUTOVERIFY') ?? '1')), ['0', 'false', 'off', 'no'], true), + ) + ); + + $container->bind(\Plugins\Auth\Infrastructure\Http\Controllers\MobileAuthController::class, + static fn(ModuleContainer $c) => + new \Plugins\Auth\Infrastructure\Http\Controllers\MobileAuthController( + $c->make(UserServiceContract::class), + $c->make(\Plugins\Auth\Application\Services\MobileAuthService::class), + ) + ); + // Default user provider (ModelUserProvider over the central identity store). // Passing AuthServiceContract lights up the HasApiTokens surface on proxies. + // The tenant gate makes membership part of the fetch: on a tenant-scoped + // request a user with no active seat in that tenant simply does not exist. $container->bind(\Plugins\Auth\Application\Ports\UserProvider::class, static fn(ModuleContainer $c) => new \Plugins\Auth\Application\Auth\ModelUserProvider( $c->make(UserServiceContract::class), 'users', ['identifier', 'email', 'username'], - $c->make(AuthServiceContract::class), + $c->make(AuthServiceContract::class) ) ); @@ -158,8 +227,22 @@ public function register(ModuleContainer $container): void new \Plugins\Auth\Application\Auth\PasswordResetBroker( $c->make(UserServiceContract::class), $c->make(CachePort::class), + otpTtlSeconds: (int) (env('AUTH_OTP_TTL') ?: 600), ) ); + + // OTP forgot-password endpoints (old __DEV__ mobile flow). MailPort + // is OPTIONAL — without a mailer the OTP is only visible in dev + // transports; the flow itself keeps working. + $container->bindInternal(\Plugins\Auth\Infrastructure\Http\Controllers\PasswordResetController::class, + static fn(ModuleContainer $c) => + new \Plugins\Auth\Infrastructure\Http\Controllers\PasswordResetController( + $c->make(\Plugins\Auth\Application\Ports\PasswordBroker::class), + $c->has(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort::class) + ? $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort::class) + : null, + ) + ); } // AuthManager — manages named guards + providers. Request is injected per @@ -176,12 +259,34 @@ public function register(ModuleContainer $container): void $c->make(UserServiceContract::class), $name, ['identifier', 'email', 'username'], - $c->make(AuthServiceContract::class), + $c->make(AuthServiceContract::class) ) : null; }, session: $c->has(SessionPort::class) ? $c->make(SessionPort::class) : null, auth: $c->make(AuthServiceContract::class), + statefulFactory: static function (string $name, \Plugins\Auth\Application\Ports\UserProvider $provider, \AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request $request) use ($c): ?\Plugins\Auth\Application\Ports\StatefulGuard { + if (!$c->has(SessionPort::class)) { + return null; + } + + // WRITE-side guard for the old flow: + // auth()->guard('web')->attempt($credentials, remember: true) + $guard = new \Plugins\Auth\Application\Auth\StatefulSessionGuard( + name: $name, + provider: $provider, + session: $c->make(SessionPort::class), + users: $c->make(UserServiceContract::class), + cookies: $c->has(\Plugins\Cookie\Infrastructure\CookieJar::class) + ? $c->make(\Plugins\Cookie\Infrastructure\CookieJar::class) + : null, + devices: $c->make(\Plugins\Auth\Application\Services\DeviceSessionService::class), + ); + + return $guard->setRequest($request); + }, + refreshTokens: $c->make(\Plugins\Auth\API\Contracts\RefreshTokenServiceContract::class), + accessTtl: (int) (env('AUTH_MOBILE_ACCESS_TTL') ?: 3600), ) ); } @@ -211,6 +316,7 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke }); } + /** * Resolve a PEM signing key from either an inline env value or a file path * (the file form is preferred in production — keys stay off the process diff --git a/plugins/Auth/README.md b/plugins/Auth/README.md index 0fb9718..6c5e7eb 100644 --- a/plugins/Auth/README.md +++ b/plugins/Auth/README.md @@ -139,15 +139,37 @@ return [ ``` ```php +// READ $manager->guard('api')->user(); // ?Authenticatable (AuthUserProxy) $manager->guard('jwt')->identity(); // kernel Identity $manager->provider('users'); $manager->extend('sso', fn($req,$name,$cfg) => new GuardAccessor(...)); $manager->extendProvider('ldap', fn($name) => new LdapUserProvider(...)); $manager->forgetGuards(); // Swoole: clear per-request cache -$manager->issueToken('u1', ['roles' => ['user']], 3600); + +// WRITE — the old front-door ergonomic (session guard): +$manager->guard('web')->attempt(['email' => …, 'password' => …], remember: true); +$manager->guard('web')->logout(); +$manager->guard('web')->logoutOtherDevices($password); +// (a stateless guard throws on a write — attempt/logout need a session driver) + +// ISSUE — stateless credentials, one call, no reaching into AuthService: +$manager->issueToken('u1', ['roles' => ['user']], 3600); // access JWT +$manager->issueTokenPair('u1', device: $ua, ip: $ip); // { accessToken, refreshToken, … } ``` +**AuthManager is the single front door.** The Auth plugin's own controllers +route through it — `SessionAuthController` drives `$this->auth('web')->attempt()` +/ `->logout()` / `->logoutOtherDevices()`; `MobileAuthController` issues via +`$this->authManager()->issueTokenPair()` / `->issueToken()` (parity with the old +`AuthManager::issueToken('mobile', …)`). Controllers never touch +`AuthService`/`RefreshTokenService` directly. The one thing AuthManager does NOT +own is verifying an INCOMING token on a protected request — that runs in the +kernel SecurityGateway (`JwtAuthLayer`/`PersonalAccessTokenLayer`) *before* any +module loads, which is a GDA requirement, not a choice. Other PLUGINS still cross +the boundary through the published `AuthServiceContract` (AuthManager is +Auth-internal, deliberately not exposed). + - **`ModelUserProvider`** — resolves users from `UserServiceContract` (no ORM); `retrieveByCredentials` does the full timing-safe verify. - **`AuthUserProxy`** — lightweight current user; `identity()` + HasApiTokens @@ -309,3 +331,65 @@ unserialize a recaller · confuse `personal_access_tokens` (user keys) with --- *OAuth 2.1 / OIDC authorization-server flows live in the `Plugins\OAuth2` plugin.* + +--- + +## Restored HKMCode flows (device sessions · mobile · OTP · social · RBAC) + +The full old-framework auth flow is available. New pieces and how to use them: + +### Web session security — fingerprint + device registry +Every stateful login is bound to a device **fingerprint** (`X-Client-Fingerprint` +header, else `sha256(ip|user-agent)`) and registered in the central +`auth_sessions` table. A request that can't reproduce the fingerprint, or whose +server-side row was revoked/expired, loses the session immediately — even if the +cookie is still live. Rolling refresh slides the expiry forward on activity. +`DeviceSessionService` orchestrates it; `config/auth.php` `session` block tunes +`ttl_days` / `refresh_days` / `client_fingerprint_header`. + +- `GET /auth/sessions` — list this user's active devices (current flagged). +- `DELETE /auth/sessions/{id}` — sign out one device. +- `POST /auth/logout-other-devices` `{ password }` — revoke every OTHER device + (re-verifies the password first). + +Run the `auth_sessions` migration (central). + +### Mobile JWT flow (`/auth/mobile/*`) +- `POST /auth/mobile/login` `{ email|identifier, password }` → `{ user, tokens }` + (access JWT + refresh). Add `client_id` + PKCE params (`redirect_uri`, `scope`, + `state`, `code_challenge`, `code_challenge_method`) to switch to the **PKCE** + shape → `{ code, state }`, exchanged at `POST /oauth/token` with the + `code_verifier`. PKCE needs the route to also require `oauth.server`. +- `POST /auth/mobile/register` → same two shapes; auto-verifies the email + (`AUTH_MOBILE_AUTOVERIFY=0` to disable). +- `POST /auth/mobile/logout` (Bearer) → blocklists the access token's `jti`. +- Refresh stays at `POST /auth/refresh` (DB-backed rotation + family reuse + detection). + +### OTP password reset (`/auth/password/*`) +`POST /auth/password/forgot` `{ email }` → always 200 (enumeration-safe), emails a +6-digit OTP via the OPTIONAL `MailPort` · `POST /auth/password/verify-otp` +`{ email, otp }` → `{ resetToken }` (single-use) · `POST /auth/password/reset` +`{ email, token, password }`. Needs `CachePort`. + +### Social sign-in (`Plugins\SocialAuth`, solves `auth.social`) +- `GET /auth/social/{driver}` → provider redirect · `GET /auth/social/{driver}/callback` + → session login + redirect (web), or `?mode=token` → `{ user, tokens }`. +- `POST /auth/social/{driver}/token` — native-SDK sign-in: verifies a Google + `access_token`/`id_token` or an Apple `identity_token` (against Apple's JWKS) + before find-or-create. Links live in central `social_identities`. + +### RBAC via Casbin (`Plugins\Authorization`, solves `authorization.policy`) +When loaded, a user's roles + effective permissions are read from the policy +store and stamped into the session and JWT claims at login/issuance +(`RoleResolver`). Protect a route declaratively: + +```jsonc +{ "method": "PUT", "path": "/api/users/{id}", "handler": "…", + "filters": ["auth", "can:users,edit"], "requires": ["authorization.policy"] } +``` + +Seed the shipped role hierarchy (super/owner/admin/…): `hkm authz:seed` +(imports `plugins/Authorization/config/policy.seed.csv`; the wildcard model in +`rbac_model.conf` treats `*` object/action as full access). Policy rules are +control-plane → central connection. diff --git a/plugins/Auth/config/auth.php b/plugins/Auth/config/auth.php index f79b165..3803d39 100644 --- a/plugins/Auth/config/auth.php +++ b/plugins/Auth/config/auth.php @@ -55,4 +55,26 @@ 'providers' => [ 'users' => ['driver' => 'model'], ], + + /* + |-------------------------------------------------------------------------- + | Stateful session security (old __DEV__ flow) + |-------------------------------------------------------------------------- + | Every web login is bound to a device fingerprint and registered in the + | central `auth_sessions` table. Requests that can't reproduce the + | fingerprint — or whose server-side row was revoked/expired — lose the + | session immediately (see DeviceSessionService). + | + | ttl_days absolute device-session lifetime + | refresh_days rolling window: inside the last N days the expiry slides + | forward a full TTL on activity + | client_fingerprint_header + | optional client-supplied fingerprint (e.g. FingerprintJS); + | falls back to sha256(ip|user-agent) + */ + 'session' => [ + 'ttl_days' => (int) (env('AUTH_SESSION_TTL') ?: 30), + 'refresh_days' => (int) (env('AUTH_SESSION_REFRESH') ?: 7), + 'client_fingerprint_header' => env('AUTH_FINGERPRINT_HEADER') ?: 'X-Client-Fingerprint', + ], ]; diff --git a/plugins/Auth/database/migrations/.gitkeep b/plugins/Auth/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/Auth/database/migrations/2026_06_05_000001_create_personal_access_tokens_table.php b/plugins/Auth/database/tenant-template/2026_06_05_000001_create_personal_access_tokens_table.php similarity index 100% rename from plugins/Auth/database/migrations/2026_06_05_000001_create_personal_access_tokens_table.php rename to plugins/Auth/database/tenant-template/2026_06_05_000001_create_personal_access_tokens_table.php diff --git a/plugins/Auth/database/migrations/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php b/plugins/Auth/database/tenant-template/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php similarity index 100% rename from plugins/Auth/database/migrations/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php rename to plugins/Auth/database/tenant-template/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php diff --git a/plugins/Auth/database/migrations/2026_07_04_000002_create_refresh_tokens_table.php b/plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php similarity index 100% rename from plugins/Auth/database/migrations/2026_07_04_000002_create_refresh_tokens_table.php rename to plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php diff --git a/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php b/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php new file mode 100644 index 0000000..cbee61a --- /dev/null +++ b/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php @@ -0,0 +1,55 @@ +hasTable('auth_sessions')) { + return; + } + + $schema->create('auth_sessions', static function ($t) { + $t->id(); + $t->char('session_id', 32)->comment('public id (list/revoke API) — not the token'); + $t->char('user_id', 31); + $t->char('token_hash', 64)->comment('SHA-256 of the session token — never store raw'); + $t->char('fingerprint', 64)->nullable()->comment('SHA-256 device fingerprint captured at login'); + $t->string('ip', 45)->nullable(); + $t->string('user_agent', 191)->nullable(); + $t->timestamp('last_seen_at')->nullable(); + $t->timestamp('expires_at'); + $t->timestamp('revoked_at')->nullable(); + $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); + + $t->unique(['session_id'], 'uniq_session_id'); + $t->unique(['token_hash'], 'uniq_token_hash'); + $t->index(['user_id', 'revoked_at'], 'idx_user_active'); + + $t->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); + + $t->engine('InnoDB'); + $t->charset('utf8mb4'); + $t->collation('utf8mb4_0900_ai_ci'); + $t->rowFormat('DYNAMIC'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('auth_sessions'); + } +}; diff --git a/plugins/Auth/module.json b/plugins/Auth/module.json index 1efe062..60b88de 100644 --- a/plugins/Auth/module.json +++ b/plugins/Auth/module.json @@ -4,7 +4,7 @@ "solves": "auth.identity", "type": "module", - "requires": ["database.management", "crypto.services", "user.management"], + "requires": ["database.management", "crypto.services", "user.management", "authorization.policy"], "exposes": [ "Plugins\\Auth\\API\\Contracts\\AuthServiceContract", "Plugins\\Auth\\API\\Contracts\\RefreshTokenServiceContract" @@ -15,6 +15,10 @@ { "method": "POST", "path": "/auth/logout", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@logout" }, { "method": "GET", "path": "/auth/me", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@me" }, + { "method": "GET", "path": "/auth/sessions", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@sessions", "filters": ["auth"] }, + { "method": "DELETE", "path": "/auth/sessions/{id}", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@revokeSession", "filters": ["auth"] }, + { "method": "POST", "path": "/auth/logout-other-devices", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@logoutOtherDevices", "filters": ["auth", "throttle:5,1"] }, + { "method": "GET", "path": "/auth/tokens", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PersonalAccessTokenController@index", "filters": ["auth"] }, { "method": "POST", "path": "/auth/tokens", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PersonalAccessTokenController@store", "filters": ["auth", "throttle:20,1"] }, { "method": "DELETE", "path": "/auth/tokens/{id}", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PersonalAccessTokenController@destroy", "filters": ["auth"] }, @@ -22,8 +26,18 @@ { "method": "POST", "path": "/auth/token/refresh", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\TransientTokenController@refresh", "filters": ["auth"] }, { "method": "POST", "path": "/auth/refresh", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\AuthTokenController@refresh", "filters": ["throttle:30,1"] }, - { "method": "POST", "path": "/auth/refresh/logout", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\AuthTokenController@logout" } + { "method": "POST", "path": "/auth/refresh/logout", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\AuthTokenController@logout" }, + + { "method": "POST", "path": "/auth/mobile/login", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@login", "filters": ["throttle:10,1"] }, + { "method": "POST", "path": "/auth/mobile/register", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@register", "filters": ["throttle:6,1"] }, + { "method": "POST", "path": "/auth/mobile/logout", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@logout", "filters": ["auth"] }, + + { "method": "POST", "path": "/auth/password/forgot", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PasswordResetController@forgot", "filters": ["throttle:5,1"] }, + { "method": "POST", "path": "/auth/password/verify-otp", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PasswordResetController@verifyOtp", "filters": ["throttle:10,1"] }, + { "method": "POST", "path": "/auth/password/reset", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PasswordResetController@reset", "filters": ["throttle:10,1"] } ], + + "views": { "path": "resources/views", "namespace": "auth", "global": false }, "emits": [], "listens": [], @@ -37,6 +51,12 @@ { "key": "JWT_KID", "type": "string", "required": false }, { "key": "AUTH_PAT_TABLE", "type": "string", "required": false }, { "key": "AUTH_REFRESH_TTL", "type": "int", "required": false }, - { "key": "AUTH_REFRESH_ACCESS_TTL", "type": "int", "required": false } + { "key": "AUTH_REFRESH_ACCESS_TTL", "type": "int", "required": false }, + { "key": "AUTH_SESSION_TTL", "type": "int", "required": false }, + { "key": "AUTH_SESSION_REFRESH", "type": "int", "required": false }, + { "key": "AUTH_FINGERPRINT_HEADER", "type": "string", "required": false }, + { "key": "AUTH_MOBILE_ACCESS_TTL", "type": "int", "required": false }, + { "key": "AUTH_MOBILE_AUTOVERIFY", "type": "string", "required": false }, + { "key": "AUTH_OTP_TTL", "type": "int", "required": false } ] } diff --git a/plugins/Auth/resources/views/password-otp.php b/plugins/Auth/resources/views/password-otp.php new file mode 100644 index 0000000..262531e --- /dev/null +++ b/plugins/Auth/resources/views/password-otp.php @@ -0,0 +1,29 @@ + + + + + +
+
+

Password Reset Code

+

+ Use the code below to reset your password. It expires in minutes. +

+
+ +
+

+ If you didn't request a password reset, you can safely ignore this email. + Your password will not be changed. +

+
+
+ + diff --git a/plugins/Authorization/API/Contracts/AuthorizationServiceContract.php b/plugins/Authorization/API/Contracts/AuthorizationServiceContract.php index c7c00ca..5b3d0fb 100644 --- a/plugins/Authorization/API/Contracts/AuthorizationServiceContract.php +++ b/plugins/Authorization/API/Contracts/AuthorizationServiceContract.php @@ -42,6 +42,15 @@ public function revokeRole(string $user, string $role, ?string $domain = null): */ public function rolesOf(string $user, ?string $domain = null): array; + /** + * Effective permissions for a user — their own grants PLUS everything + * inherited through the role hierarchy, flattened to "object:action" + * strings (the platform Identity->permissions convention). + * + * @return list + */ + public function permissionsOf(string $user, ?string $domain = null): array; + /** * Add a permission policy rule: subject can do action on object. */ diff --git a/plugins/Authorization/Application/Services/AuthorizationService.php b/plugins/Authorization/Application/Services/AuthorizationService.php index f9e4ee4..108d905 100644 --- a/plugins/Authorization/Application/Services/AuthorizationService.php +++ b/plugins/Authorization/Application/Services/AuthorizationService.php @@ -63,6 +63,26 @@ public function rolesOf(string $user, ?string $domain = null): array : $this->enforcer->getRolesForUserInDomain($user, $domain); } + /** @return list effective (own + role-inherited) "object:action" grants */ + public function permissionsOf(string $user, ?string $domain = null): array + { + $rules = $domain === null + ? $this->enforcer->getImplicitPermissionsForUser($user) + : $this->enforcer->getImplicitPermissionsForUser($user, $domain); + + $permissions = []; + foreach ($rules as $rule) { + // Rule shape: [sub, obj, act] (+ optional extras) — flatten to obj:act. + $object = (string) ($rule[1] ?? ''); + $action = (string) ($rule[2] ?? ''); + if ($object !== '' && $action !== '') { + $permissions[$object . ':' . $action] = true; + } + } + + return array_keys($permissions); + } + public function grant(string $subject, string $object, string $action, string ...$extra): bool { return $this->enforcer->addPolicy($subject, $object, $action, ...$extra); diff --git a/plugins/Authorization/Engine/Log/Logger/DefaultLogger.php b/plugins/Authorization/Engine/Log/Logger/DefaultLogger.php index d294659..da9cd9f 100644 --- a/plugins/Authorization/Engine/Log/Logger/DefaultLogger.php +++ b/plugins/Authorization/Engine/Log/Logger/DefaultLogger.php @@ -49,7 +49,12 @@ public function __construct(?LoggerInterface $psrLogger = null) public function __construct() { - $this->path = app()->logsPath('casbin.log'); + // GDA: no framework globals inside the engine. Resolve the log + // path from the kernel Paths helper, falling back to the system + // temp dir when it is unavailable (tests / standalone use). + $this->path = class_exists(\AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths::class) + ? \AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths::logs('casbin.log') + : sys_get_temp_dir() . '/casbin.log'; } public function log($level, $message, array $context = []): void diff --git a/plugins/Authorization/Engine/functions.php b/plugins/Authorization/Engine/functions.php new file mode 100644 index 0000000..088ba41 --- /dev/null +++ b/plugins/Authorization/Engine/functions.php @@ -0,0 +1,78 @@ + + */ + function extractEvalParameters(string $expression): array + { + preg_match_all('/\beval\(([^)]*)\)/', $expression, $matches); + + return array_values(array_map('trim', $matches[1] ?? [])); + } +} + +if (!function_exists('replaceEvalWithMappings')) { + /** + * Replace each `eval()` with the mapped rule expression, wrapped in + * parentheses so operator precedence is preserved. + * + * @param array $mappings ruleName => rule expression + */ + function replaceEvalWithMappings(string $expression, array $mappings): string + { + return (string) preg_replace_callback( + '/\beval\(([^)]*)\)/', + static function (array $m) use ($mappings): string { + $ruleName = trim($m[1]); + + return isset($mappings[$ruleName]) ? '(' . $mappings[$ruleName] . ')' : $m[0]; + }, + $expression, + ); + } +} diff --git a/plugins/Authorization/Infrastructure/Cli/SeedPolicyCommand.php b/plugins/Authorization/Infrastructure/Cli/SeedPolicyCommand.php new file mode 100644 index 0000000..5fff255 --- /dev/null +++ b/plugins/Authorization/Infrastructure/Cli/SeedPolicyCommand.php @@ -0,0 +1,100 @@ +enforcerFactory = $enforcerFactory; + parent::__construct(); + } + + protected function configure(): void + { + $this->name = 'authz:seed'; + $this->description = 'Seed the Casbin policy table from a policy CSV file'; + + $this->addOption('file', '', 'Path to the policy CSV', acceptsValue: true, default: ''); + $this->addOption('dry', '', 'Parse and report without writing'); + } + + protected function handle(): int + { + $file = (string) $this->option('file') ?: $this->defaultFile; + if (!is_readable($file)) { + $this->error("Policy file [{$file}] is not readable."); + + return self::FAILURE; + } + + $policies = []; + $groupings = []; + + foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + + $parts = array_map('trim', explode(',', $line)); + $type = array_shift($parts); + + if ($type === 'p' && \count($parts) >= 3) { + $policies[] = $parts; + } elseif ($type === 'g' && \count($parts) >= 2) { + $groupings[] = $parts; + } + } + + $this->info(\sprintf('Parsed %d policy rule(s) and %d role assignment(s) from %s.', \count($policies), \count($groupings), $file)); + + if ($this->hasOption('dry')) { + $this->info('Dry run — nothing written.'); + + return self::SUCCESS; + } + + $enforcer = ($this->enforcerFactory)(); + + $added = 0; + foreach ($policies as $rule) { + if ($enforcer->addPolicy(...$rule)) { + $added++; + } + } + foreach ($groupings as $rule) { + if ($enforcer->addGroupingPolicy(...$rule)) { + $added++; + } + } + + $skipped = (\count($policies) + \count($groupings)) - $added; + $this->info("Seeded {$added} new rule(s); {$skipped} already present."); + + return self::SUCCESS; + } +} diff --git a/plugins/Authorization/Infrastructure/Http/Stages/PolicyFilterStage.php b/plugins/Authorization/Infrastructure/Http/Stages/PolicyFilterStage.php new file mode 100644 index 0000000..4537439 --- /dev/null +++ b/plugins/Authorization/Infrastructure/Http/Stages/PolicyFilterStage.php @@ -0,0 +1,63 @@ +userId, object, action) against the + * Casbin policy. FAIL-CLOSED: a guest, a missing enforcer (the route forgot to + * require authorization.policy), or a deny all yield an error response — + * never a pass-through. + */ +final class PolicyFilterStage implements HttpStageContract +{ + public function handle(Request $request, callable $next): Response + { + $args = (array) ($request->attribute('filter_args')['can'] ?? []); + $object = trim((string) ($args[0] ?? '')); + $action = trim((string) ($args[1] ?? '')); + + if ($object === '' || $action === '') { + // A malformed filter declaration is a config bug — fail closed loudly. + return Response::serverError(); + } + + $identity = $request->identity(); + if ($identity === null || $identity->isGuest()) { + return Response::unauthorized('Authentication required.'); + } + + $container = $request->container(); + if ($container === null || !$container->has(AuthorizationServiceContract::class)) { + // Policy module not loaded for this route → the declaration is + // incomplete (missing "requires": ["authorization.policy"]). + return Response::json(['error' => [ + 'code' => 'authorization.unavailable', + 'message' => 'This route declares a policy filter but the authorization module is not loaded.', + ]], 500); + } + + $authz = $container->make(AuthorizationServiceContract::class); + if (!$authz instanceof AuthorizationServiceContract + || !$authz->allows($identity->userId, $object, $action)) { + return Response::forbidden('You are not allowed to perform this action.'); + } + + return $next($request); + } +} diff --git a/plugins/Authorization/Provider.php b/plugins/Authorization/Provider.php index 212fe10..b05ee4f 100644 --- a/plugins/Authorization/Provider.php +++ b/plugins/Authorization/Provider.php @@ -10,11 +10,11 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Cli\CliPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; use Plugins\Authorization\API\Contracts\AuthorizationServiceContract; use Plugins\Authorization\Application\Services\AuthorizationService; use Plugins\Authorization\Engine\Enforcer; use Plugins\Authorization\Infrastructure\Persistence\DatabasePolicyAdapter; +use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; /** * Authorization plugin — Casbin RBAC/ABAC policy engine. @@ -35,7 +35,7 @@ public function solves(): string /** @return list */ public function requires(): array { - return [DatabasePort::class]; + return [DatabaseConnectionManagerContract::class]; } /** @return list */ @@ -46,10 +46,13 @@ public function exposes(): array public function register(ModuleContainer $container): void { - // Casbin policy storage adapter — DatabasePort only. + // Casbin policy storage adapter. Policy rules are CONTROL-PLANE data + // (roles/permissions are global, not tenant data), so pin to the central + // connection — the same store the authz:seed CLI writes to, so seeded + // policies are visible to runtime enforcement. $container->bindInternal(DatabasePolicyAdapter::class, static fn(ModuleContainer $c) => new DatabasePolicyAdapter( - $c->make(DatabasePort::class), + $c->make(DatabaseConnectionManagerContract::class)->default(), env('AUTHZ_POLICY_TABLE') ?: 'casbin_rule', ) ); @@ -68,6 +71,37 @@ public function register(ModuleContainer $container): void public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void { - // No pipeline hooks: authorization is invoked from Service layers on demand. + // Declarative route filter: "filters": ["can:users,edit"] enforces the + // Casbin policy for the route (the route must also carry + // "requires": ["authorization.policy"] so this module is loaded). + $http->filter('can', \Plugins\Authorization\Infrastructure\Http\Stages\PolicyFilterStage::class); + + // authz:seed — import a policy CSV into the DB policy table. Deferred so + // only CLI processes pay for it; builds its own enforcer over the + // central connection (policy rules are control-plane data). + $cli->defer(static function (CliPipeline $cli): void { + $c = new ModuleContainer($cli->container()); + $c->setScope('database.management'); + (new \Plugins\Database\Provider())->register($c); + + // Lazy: building an Enforcer loads policy from the DB, so defer it + // until the command actually runs (not at CLI registration time). + $enforcerFactory = static function () use ($c): Enforcer { + $adapter = new DatabasePolicyAdapter( + $c->make(\Plugins\Database\API\Contracts\DatabaseConnectionManagerContract::class)->default(), + env('AUTHZ_POLICY_TABLE') ?: 'casbin_rule', + ); + + return new Enforcer( + env('AUTHZ_MODEL_PATH') ?: __DIR__ . '/config/rbac_model.conf', + $adapter, + ); + }; + + $cli->command(new \Plugins\Authorization\Infrastructure\Cli\SeedPolicyCommand( + $enforcerFactory, + __DIR__ . '/config/policy.seed.csv', + )); + }); } } diff --git a/plugins/Authorization/config/policy.seed.csv b/plugins/Authorization/config/policy.seed.csv new file mode 100644 index 0000000..f38bd7b --- /dev/null +++ b/plugins/Authorization/config/policy.seed.csv @@ -0,0 +1,175 @@ +# ============================================= +# SUPER ROLE - Full access +# ============================================= +p, super, *, * + +# ============================================= +# OWNER ROLE - Full access except system-level super controls +# ============================================= +p, owner, *, * + +# ============================================= +# ADMIN ROLE - Module-level full access +# ============================================= +p, admin, users, * +p, admin, roles, * +p, admin, permissions, * +p, admin, products, * +p, admin, inventory, * +p, admin, orders, * +p, admin, customers, * +p, admin, suppliers, * +p, admin, payments, * +p, admin, refunds, * +p, admin, reports, * +p, admin, settings, * +p, admin, discounts, * +p, admin, giftcards, * +p, admin, tax, * +p, admin, branches, * +p, admin, integrations, * +p, admin, webhooks, * +p, admin, pos_terminal, * +p, admin, dashboard, read +p, admin, audit_logs, read + +# ============================================= +# MANAGER ROLE - Day-to-day operations +# ============================================= +p, manager, products, * +p, manager, inventory, * +p, manager, orders, * +p, manager, customers, * +p, manager, refunds, * +p, manager, discounts, * +p, manager, giftcards, * +p, manager, pos_terminal, * +p, manager, reports, read +p, manager, dashboard, read + +# ============================================= +# SUPERVISOR ROLE - Limited operations +# ============================================= +p, supervisor, products, read +p, supervisor, inventory, * +p, supervisor, orders, read +p, supervisor, refunds, create +p, supervisor, pos_terminal, * +p, supervisor, reports, read +p, supervisor, dashboard, read + +# ============================================= +# CASHIER ROLE - POS operations +# ============================================= +p, cashier, pos_terminal, * +p, cashier, orders, create +p, cashier, orders, checkout +p, cashier, orders, read +p, cashier, refunds, create +p, cashier, customers, create +p, cashier, customers, read +p, cashier, payments, create +p, cashier, products, read +p, cashier, dashboard, read + +# ============================================= +# INVENTORY CLERK ROLE - Stock management +# ============================================= +p, inventory_clerk, products, read +p, inventory_clerk, inventory, * +p, inventory_clerk, suppliers, read +p, inventory_clerk, reports, read + +# ============================================= +# ACCOUNTANT ROLE - Finance and auditing +# ============================================= +p, accountant, payments, * +p, accountant, refunds, * +p, accountant, reports, * +p, accountant, tax, * +p, accountant, audit_logs, read +p, accountant, dashboard, read + +# ============================================= +# SUPPORT ROLE - Customer support +# ============================================= +p, support, customers, read +p, support, orders, read +p, support, refunds, read +p, support, audit_logs, read +p, support, dashboard, read + +# ============================================= +# VIEWER ROLE - Read-only access +# ============================================= +p, viewer, dashboard, read +p, viewer, reports, read +p, viewer, products, read +p, viewer, inventory, read +p, viewer, orders, read +p, viewer, customers, read + +# ============================================= +# HKMRENTAL — GUEST +# Unauthenticated visitors; read-only public listing surface. +# ============================================= +p, guest, rental.listings, read + +# ============================================= +# HKMRENTAL — TENANT +# Registered user looking to rent a property. +# - Browse and search listings +# - Manage own wishlist, bookings, and inquiries +# - View own payment history +# - Full account self-management (profile, notifications, security, etc.) +# - Messaging with landlords and brokers +# ============================================= +p, tenant, rental.listings, read +p, tenant, rental.account, * +p, tenant, rental.chat, * +p, tenant, rental.wishlist, * +p, tenant, rental.booking, * +p, tenant, rental.inquiry, write +p, tenant, rental.payment, read + +# ============================================= +# HKMRENTAL — LANDLORD +# Property owner; manages their own units, tenants, and rent collection. +# - All tenant-facing browsing rights (market awareness) +# - Full landlord module: dashboard, properties, tenants, schedules, +# receipts, payments, profile, pulse heartbeat +# - Full account self-management +# - Messaging +# ============================================= +p, landlord, rental.listings, read +p, landlord, rental.account, * +p, landlord, rental.chat, * +p, landlord, rental.landlord, * + +# ============================================= +# HKMRENTAL — BROKER +# Licensed agent; manages listings, showings, and client relationships. +# - Public listing browsing (market awareness) +# - Full broker module: dashboard, listings, showings, clients, +# commission, landlord search & connection, profile +# - Full account self-management +# - Messaging +# ============================================= +p, broker, rental.listings, read +p, broker, rental.account, * +p, broker, rental.chat, * +p, broker, rental.broker, * + +# ============================================= +# HKMRENTAL — PROPERTY_MANAGER +# Company managing multiple landlords' portfolios. +# - Public listing browsing (market awareness) +# - Full company module: dashboard, landlords, properties, +# maintenance queue, financial summaries, reports +# - Full account self-management +# - Messaging +# ============================================= +p, property_manager, rental.listings, read +p, property_manager, rental.account, * +p, property_manager, rental.chat, * +p, property_manager, rental.company, * \ No newline at end of file diff --git a/plugins/Authorization/config/rbac_model.conf b/plugins/Authorization/config/rbac_model.conf index 9ca4b92..fa56a93 100644 --- a/plugins/Authorization/config/rbac_model.conf +++ b/plugins/Authorization/config/rbac_model.conf @@ -10,5 +10,8 @@ g = _, _ [policy_effect] e = some(where (p.eft == allow)) +# Wildcard-aware matcher (old __DEV__ model): a policy may grant "*" as the +# object and/or action, so "p, super, *, *" is full access and +# "p, admin, users, *" is module-wide access. [matchers] -m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act +m = g(r.sub, p.sub) && (p.obj == "*" || r.obj == p.obj) && (p.act == "*" || r.act == p.act) diff --git a/plugins/Authorization/database/migrations/.gitkeep b/plugins/Authorization/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/Authorization/database/migrations/2026_06_05_000001_create_casbin_rule_table.php b/plugins/Authorization/database/tenant-template/2026_06_05_000001_create_casbin_rule_table.php similarity index 100% rename from plugins/Authorization/database/migrations/2026_06_05_000001_create_casbin_rule_table.php rename to plugins/Authorization/database/tenant-template/2026_06_05_000001_create_casbin_rule_table.php diff --git a/plugins/Authorization/module.json b/plugins/Authorization/module.json index 026a483..4b7f0b0 100644 --- a/plugins/Authorization/module.json +++ b/plugins/Authorization/module.json @@ -4,7 +4,7 @@ "solves": "authorization.policy", "type": "module", - "requires": ["database.query"], + "requires": ["database.management"], "exposes": ["Plugins\\Authorization\\API\\Contracts\\AuthorizationServiceContract"], "routes": [], diff --git a/plugins/Feedback/Application/Services/FeedbackService.php b/plugins/Feedback/Application/Services/FeedbackService.php index 700266a..16270a6 100644 --- a/plugins/Feedback/Application/Services/FeedbackService.php +++ b/plugins/Feedback/Application/Services/FeedbackService.php @@ -16,7 +16,7 @@ use Plugins\Feedback\Application\Ports\FeedbackStore; use Plugins\Feedback\Domain\Entities\FeedbackEntry; use Plugins\Feedback\Domain\ValueObjects\FeedbackStatus; -use Plugins\Feedback\Infrastructure\Audit\AuditLogger; +use Plugins\Audit\API\Contracts\AuditServiceContract; /** * FeedbackService — orchestrates user feedback. @@ -41,7 +41,7 @@ public function __construct( private readonly FeedbackStore $repository, private readonly EventBus $eventBus, private readonly Identity $identity, - private readonly AuditLogger $audit, + private readonly AuditServiceContract $audit, ) {} public function submit(SubmitFeedbackDTO $dto): FeedbackEntry @@ -81,7 +81,7 @@ public function submit(SubmitFeedbackDTO $dto): FeedbackEntry occurredAt: $entry->createdAt()->format(\DateTimeInterface::RFC3339), )); - $this->audit->record('feedback.submitted', ['feedbackId' => $entry->id()->value()]); + $this->audit->record('feedback.submitted', meta: ['feedbackId' => $entry->id()->value()]); return $entry; } @@ -147,7 +147,7 @@ public function updateStatus(string $feedbackId, string $status): ?FeedbackEntry return null; } - $this->audit->record('feedback.status_changed', [ + $this->audit->record('feedback.status_changed', meta: [ 'feedbackId' => $feedbackId, 'status' => $entry->status()->value, ]); diff --git a/plugins/Feedback/Infrastructure/Audit/AuditLogger.php b/plugins/Feedback/Infrastructure/Audit/AuditLogger.php deleted file mode 100644 index f5a8486..0000000 --- a/plugins/Feedback/Infrastructure/Audit/AuditLogger.php +++ /dev/null @@ -1,106 +0,0 @@ -sink = $sink ?? static fn(string $line) => error_log($line); - } - - /** @param array $context */ - public function record(string $action, array $context = []): void - { - $occurredAt = (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339); - - $entry = json_encode([ - 'source' => 'user_audit', - 'action' => $action, - 'actor' => $this->actorId, - 'context' => $context, - 'timestamp' => $occurredAt, - ], JSON_UNESCAPED_SLASHES); - - if ($entry !== false) { - ($this->sink)($entry); - } - - $this->persist($action, $context, $occurredAt); - } - - /** - * Persist to the shared `audit_log` table. Best-effort: any failure is - * swallowed (already captured in the log line) so auditing never aborts the - * audited action. `userId` in context maps to the user_id column; everything - * else is kept in the JSON `meta` column. - * - * @param array $context - */ - private function persist(string $action, array $context, string $occurredAt): void - { - if ($this->db === null) { - return; - } - - $userId = isset($context['userId']) ? (string) $context['userId'] : ($this->actorId ?: null); - $ip = isset($context['ip']) ? (string) $context['ip'] : null; - - $meta = $context; - unset($meta['userId'], $meta['ip']); - $metaJson = $meta === [] ? null : json_encode($meta, JSON_UNESCAPED_SLASHES); - - try { - $this->db->execute( - 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) - VALUES (:event_id, :user_id, :tenant_id, :action, :ip, :meta, :occurred_at)', - [ - 'event_id' => Ulid::generate(), - 'user_id' => $userId, - 'tenant_id' => ($this->tenantId ?? '') !== '' ? $this->tenantId : null, - 'action' => $action, - 'ip' => $ip, - 'meta' => $metaJson === false ? null : $metaJson, - 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\Throwable) { - // Best-effort — the log line above is the durable fallback. - } - } -} diff --git a/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php b/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php index 8453dfa..cbc7207 100644 --- a/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php +++ b/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php @@ -83,7 +83,10 @@ public function find(string $feedbackId): ?FeedbackEntry public function paginate(ListFeedbackQuery $query): array { - $params = ['limit' => $query->limit + 1]; + // Inline LIMIT as a validated int: bound params bind as strings and + // native prepares (EMULATE_PREPARES=false) reject `LIMIT '100'`. + $limit = max(1, min(1001, $query->limit + 1)); + $params = []; $where = []; if ($query->status !== null) { @@ -103,7 +106,7 @@ public function paginate(ListFeedbackQuery $query): array $rows = $this->db->query( 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . $clause . ' ORDER BY id DESC - LIMIT :limit', + LIMIT ' . $limit, $params, ); } catch (\Throwable $e) { diff --git a/plugins/Feedback/Provider.php b/plugins/Feedback/Provider.php index 3302efa..db1e117 100644 --- a/plugins/Feedback/Provider.php +++ b/plugins/Feedback/Provider.php @@ -12,9 +12,9 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\Feedback\Application\Services\FeedbackService; -use Plugins\Feedback\Infrastructure\Audit\AuditLogger; use Plugins\Feedback\Infrastructure\Http\Controllers\FeedbackController; use Plugins\Feedback\Infrastructure\Persistence\FeedbackRepository; @@ -40,6 +40,7 @@ public function requires(): array { return [ DatabaseConnectionManagerContract::class, + AuditServiceContract::class, ]; } @@ -57,25 +58,12 @@ public function register(ModuleContainer $container): void $container->bindInternal(FeedbackRepository::class, static fn(ModuleContainer $c) => new FeedbackRepository($c->make(DatabasePort::class))); - // Audit persists to the shared CENTRAL `audit_log` table + a log line. - // The active tenant is published by Tenancy's TenantContextStage under - // the 'tenant.current' container key (a plain string — no Tenancy import). - $container->bindInternal(AuditLogger::class, static function (ModuleContainer $c) { - $identity = $c->make(Identity::class); - $tenantId = $c->has('tenant.current') ? (string) $c->make('tenant.current') : null; - return new AuditLogger( - $identity->userId ?: null, - db: self::central($c), - tenantId: $tenantId, - ); - }); - $container->bindInternal(FeedbackService::class, static fn(ModuleContainer $c) => new FeedbackService( repository: $c->make(FeedbackRepository::class), eventBus: $c->make(EventBus::class), identity: $c->make(Identity::class), - audit: $c->make(AuditLogger::class), + audit: $c->make(AuditServiceContract::class), )); $container->bindInternal(FeedbackController::class, static fn(ModuleContainer $c) => @@ -86,10 +74,4 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke { // No pipeline hooks or subscriptions — routes carry the wiring. } - - /** The CENTRAL connection (owns the shared `audit_log` table). */ - private static function central(ModuleContainer $c): DatabasePort - { - return $c->make(DatabaseConnectionManagerContract::class)->default(); - } } diff --git a/plugins/Feedback/module.json b/plugins/Feedback/module.json index eec7cdd..ed07e32 100644 --- a/plugins/Feedback/module.json +++ b/plugins/Feedback/module.json @@ -3,21 +3,54 @@ "version": "1.0.0", "solves": "feedback.management", "type": "module", - - "requires": ["database.management"], + "requires": [ + "database.management", + "audit.trail" + ], "exposes": [], - "routes": [ - { "method": "POST", "path": "/ajx/feedback", "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@submit", "filters": ["auth", "tenant", "throttle:5,1"] }, - { "method": "GET", "path": "/ajx/feedback", "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@index", "filters": ["auth", "tenant"] }, - { "method": "GET", "path": "/ajx/feedback/{id}", "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@show", "filters": ["auth", "tenant"] }, - { "method": "PATCH", "path": "/ajx/feedback/{id}", "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@updateStatus", "filters": ["auth", "tenant"] } + { + "method": "POST", + "path": "/ajx/feedback", + "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@submit", + "filters": [ + "auth", + "tenant", + "throttle:5,1" + ] + }, + { + "method": "GET", + "path": "/ajx/feedback", + "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@index", + "filters": [ + "auth", + "tenant" + ] + }, + { + "method": "GET", + "path": "/ajx/feedback/{id}", + "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@show", + "filters": [ + "auth", + "tenant" + ] + }, + { + "method": "PATCH", + "path": "/ajx/feedback/{id}", + "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@updateStatus", + "filters": [ + "auth", + "tenant" + ] + } + ], + "emits": [ + "feedback.submitted" ], - - "emits": ["feedback.submitted"], "listens": [], - - "documentation": "The Feedback plugin — owns the feedback.management domain (extracted from the User plugin so each plugin owns one domain). Users submit categorised, rated feedback attributed to their authenticated Identity; admins triage it (feedback:manage). Rows live in the request's TENANT database (repository bound to the tenant-routed DatabasePort); the integration event feedback.submitted is dispatched only AFTER the write succeeds. Security-relevant actions are audited to the shared central audit_log table. Enabling publishes database/ (the user_feedback tenant-template migration).", - + "documentation": "The Feedback plugin \u2014 owns the feedback.management domain (extracted from the User plugin so each plugin owns one domain). Users submit categorised, rated feedback attributed to their authenticated Identity; admins triage it (feedback:manage). Rows live in the request's TENANT database (repository bound to the tenant-routed DatabasePort); the integration event feedback.submitted is dispatched only AFTER the write succeeds. Security-relevant actions are audited to the shared central audit_log table. Enabling publishes database/ (the user_feedback tenant-template migration).", "config": [] } diff --git a/plugins/OAuth2/Application/Ports/AuthorizationFlow.php b/plugins/OAuth2/Application/Ports/AuthorizationFlow.php new file mode 100644 index 0000000..7340ac6 --- /dev/null +++ b/plugins/OAuth2/Application/Ports/AuthorizationFlow.php @@ -0,0 +1,34 @@ + $params client_id, redirect_uri, scope, state, + * code_challenge, code_challenge_method. + * response_type defaults to 'code'. + * @return array{code:string,state:string,redirect_uri:string} + * @throws OAuthException when the client/redirect/scope/PKCE is invalid + */ + public function issueCodeFor(array $params, string $userId): array; +} diff --git a/plugins/OAuth2/Application/Services/AuthorizationService.php b/plugins/OAuth2/Application/Services/AuthorizationService.php index 405a509..1265d1c 100644 --- a/plugins/OAuth2/Application/Services/AuthorizationService.php +++ b/plugins/OAuth2/Application/Services/AuthorizationService.php @@ -5,6 +5,7 @@ namespace Plugins\OAuth2\Application\Services; use Plugins\OAuth2\Application\Ports\AuthCodeStore; +use Plugins\OAuth2\Application\Ports\AuthorizationFlow; use Plugins\OAuth2\Application\Ports\ClientStore; use Plugins\OAuth2\Domain\Entities\AuthCode; use Plugins\OAuth2\Domain\Exceptions\OAuthException; @@ -25,7 +26,7 @@ * - PKCE is MANDATORY for public clients; S256 strongly preferred. * - code is random, hashed at rest, 60s TTL, single-use. */ -final class AuthorizationService +final class AuthorizationService implements AuthorizationFlow { public function __construct( private readonly ClientStore $clients, @@ -129,6 +130,28 @@ public function issueCode(AuthorizationRequest $req, string $userId): string ]); } + /** + * Headless validate + issue for a first-party, already-authenticated user + * (AuthorizationFlow port — the old __DEV__ mobile login/register flow). + * Consent is skipped; every other check (client, redirect_uri exact match, + * scopes, PKCE-for-public-clients) still runs. + */ + public function issueCodeFor(array $params, string $userId): array + { + $params['response_type'] = $params['response_type'] ?? 'code'; + + $request = $this->validate($params); + $redirect = $this->issueCode($request, $userId); + + parse_str((string) parse_url($redirect, PHP_URL_QUERY), $query); + + return [ + 'code' => (string) ($query['code'] ?? ''), + 'state' => (string) ($query['state'] ?? $request->state), + 'redirect_uri' => $request->redirectUri, + ]; + } + /** Build a redirect URL, appending params to any existing query string. */ public function buildRedirect(string $uri, array $params): string { diff --git a/plugins/OAuth2/Provider.php b/plugins/OAuth2/Provider.php index 9684361..a07aef6 100644 --- a/plugins/OAuth2/Provider.php +++ b/plugins/OAuth2/Provider.php @@ -79,7 +79,10 @@ public function requires(): array /** @return list */ public function exposes(): array { - return [ClientStore::class]; + return [ + ClientStore::class, + \Plugins\OAuth2\Application\Ports\AuthorizationFlow::class, + ]; } public function register(ModuleContainer $container): void @@ -129,6 +132,11 @@ public function register(ModuleContainer $container): void (int) (env('OAUTH_CODE_TTL') ?: 60), )); + // Published port: headless code issuance for first-party authenticated + // flows (Auth's mobile login/register — old __DEV__ PKCE-without-browser). + $container->bind(\Plugins\OAuth2\Application\Ports\AuthorizationFlow::class, + static fn(ModuleContainer $c) => $c->make(AuthorizationService::class)); + $container->bindInternal(TokenService::class, static fn(ModuleContainer $c) => new TokenService( $c->make(ClientStore::class), diff --git a/plugins/OAuth2/database/migrations/.gitkeep b/plugins/OAuth2/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000010_create_oauth_clients_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000010_create_oauth_clients_table.php similarity index 100% rename from plugins/OAuth2/database/migrations/2026_06_27_000010_create_oauth_clients_table.php rename to plugins/OAuth2/database/tenant-template/2026_06_27_000010_create_oauth_clients_table.php diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000011_create_oauth_auth_codes_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000011_create_oauth_auth_codes_table.php similarity index 100% rename from plugins/OAuth2/database/migrations/2026_06_27_000011_create_oauth_auth_codes_table.php rename to plugins/OAuth2/database/tenant-template/2026_06_27_000011_create_oauth_auth_codes_table.php diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000012_create_oauth_refresh_tokens_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000012_create_oauth_refresh_tokens_table.php similarity index 100% rename from plugins/OAuth2/database/migrations/2026_06_27_000012_create_oauth_refresh_tokens_table.php rename to plugins/OAuth2/database/tenant-template/2026_06_27_000012_create_oauth_refresh_tokens_table.php diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000013_create_oauth_scopes_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000013_create_oauth_scopes_table.php similarity index 100% rename from plugins/OAuth2/database/migrations/2026_06_27_000013_create_oauth_scopes_table.php rename to plugins/OAuth2/database/tenant-template/2026_06_27_000013_create_oauth_scopes_table.php diff --git a/plugins/OAuth2/database/migrations/2026_06_27_000014_create_oauth_device_codes_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000014_create_oauth_device_codes_table.php similarity index 100% rename from plugins/OAuth2/database/migrations/2026_06_27_000014_create_oauth_device_codes_table.php rename to plugins/OAuth2/database/tenant-template/2026_06_27_000014_create_oauth_device_codes_table.php diff --git a/plugins/OAuth2/database/migrations/2026_07_04_000001_add_owner_to_oauth_clients.php b/plugins/OAuth2/database/tenant-template/2026_07_04_000001_add_owner_to_oauth_clients.php similarity index 100% rename from plugins/OAuth2/database/migrations/2026_07_04_000001_add_owner_to_oauth_clients.php rename to plugins/OAuth2/database/tenant-template/2026_07_04_000001_add_owner_to_oauth_clients.php diff --git a/plugins/OAuth2/module.json b/plugins/OAuth2/module.json index 1e7281a..413ac02 100644 --- a/plugins/OAuth2/module.json +++ b/plugins/OAuth2/module.json @@ -5,7 +5,10 @@ "type": "module", "requires": ["database.management", "crypto.services", "user.management", "view.rendering"], - "exposes": ["Plugins\\OAuth2\\Application\\Ports\\ClientStore"], + "exposes": [ + "Plugins\\OAuth2\\Application\\Ports\\ClientStore", + "Plugins\\OAuth2\\Application\\Ports\\AuthorizationFlow" + ], "views": "resources/views", diff --git a/plugins/SecurityFilters/Infrastructure/Http/Stages/ApiRateLimitStage.php b/plugins/SecurityFilters/Infrastructure/Http/Stages/ApiRateLimitStage.php index 3577dc6..6e77560 100644 --- a/plugins/SecurityFilters/Infrastructure/Http/Stages/ApiRateLimitStage.php +++ b/plugins/SecurityFilters/Infrastructure/Http/Stages/ApiRateLimitStage.php @@ -10,11 +10,20 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\Contracts\HttpStageContract; /** - * Sliding-window rate limiter for API routes (GDA rewrite of ApiRateLimit). + * Sliding-window rate limiter (GDA rewrite of ApiRateLimit). + * + * Runs in TWO modes, chosen per request: + * + * 1. DECLARATIVE (route filter) — the route declared `throttle:MAX,MINUTES` + * (e.g. `throttle:5,10` = 5 requests / 10 minutes). The limit comes from the + * route's own args and the counter is scoped PER ROUTE, so a strict cap on + * one endpoint never eats another endpoint's budget. The route opting in IS + * the scope decision — no path-prefix gate. + * 2. GLOBAL (if ever wired as a hook) — falls back to RATE_LIMIT_* env values + * and only enforces under RATE_LIMIT_PREFIX. * * Counters live in CachePort with TTL auto-expiry. Identity prefers the * authenticated user id (from the kernel Identity), falling back to client IP. - * Only enforces on paths under RATE_LIMIT_PREFIX so the router decides scope. * * Registered at the `after.load` slot: hooked stages are built with `new` from * the CoreContainer, so the CachePort is resolved from the request-scoped @@ -24,9 +33,24 @@ final class ApiRateLimitStage implements HttpStageContract { public function handle(Request $request, callable $next): Response { - $prefix = (string) (env('RATE_LIMIT_PREFIX') ?: '/api'); - if (!str_starts_with($request->path(), $prefix)) { - return $next($request); + $declared = in_array('throttle', (array) $request->attribute('active_filters'), true); + + if ($declared) { + // "throttle:MAX,MINUTES" — args parsed by RouteFilterStage. + $args = (array) ($request->attribute('filter_args')['throttle'] ?? []); + $max = max(1, (int) ($args[0] ?? 60)); + $window = max(1, (int) ($args[1] ?? 1)) * 60; // minutes → seconds + // Per-route bucket: method + matched route pattern (not the concrete + // path, so /users/{id} shares one bucket across ids). + $scope = $this->routeScope($request); + } else { + $prefix = (string) (env('RATE_LIMIT_PREFIX') ?: '/api'); + if (!str_starts_with($request->path(), $prefix)) { + return $next($request); + } + $max = max(1, (int) (env('RATE_LIMIT_MAX') ?: 300)); + $window = max(1, (int) (env('RATE_LIMIT_WINDOW') ?: 60)); + $scope = 'global'; } $cache = $this->resolveCache($request); @@ -35,10 +59,7 @@ public function handle(Request $request, callable $next): Response return $next($request); } - $max = max(1, (int) (env('RATE_LIMIT_MAX') ?: 300)); - $window = max(1, (int) (env('RATE_LIMIT_WINDOW') ?: 60)); - - $key = 'rl_' . hash('sha256', $this->resolveIdentity($request)); + $key = 'rl_' . hash('sha256', $scope . '|' . $this->resolveIdentity($request)); $current = (int) ($cache->get($key) ?? 0); if ($current >= $max) { @@ -76,6 +97,15 @@ private function resolveCache(Request $request): ?CachePort return $cache instanceof CachePort ? $cache : null; } + /** Per-route bucket key: METHOD + the matched route pattern (falls back to path). */ + private function routeScope(Request $request): string + { + $entry = $request->attribute('route_entry'); + $path = is_array($entry) ? (string) ($entry['path'] ?? $request->path()) : $request->path(); + + return 'route_' . $request->method() . ' ' . $path; + } + private function resolveIdentity(Request $request): string { $identity = $request->identity(); diff --git a/plugins/SocialAuth/Application/Services/SocialLoginService.php b/plugins/SocialAuth/Application/Services/SocialLoginService.php new file mode 100644 index 0000000..cb413cc --- /dev/null +++ b/plugins/SocialAuth/Application/Services/SocialLoginService.php @@ -0,0 +1,144 @@ +normalizedEmail($profile['email'] ?? null); + $name = $profile['name'] ?? null; + $avatar = $profile['avatar'] ?? null; + + // 1 — already linked. + $userId = $this->identities->findUserId($provider, $providerUserId); + if ($userId !== null) { + $user = $this->users->find($userId); + if ($user !== null) { + // Refresh the provider snapshot (best-effort). + $this->identities->link($provider, $providerUserId, $userId, $email, $name, $avatar); + + return $user; + } + } + + if ($email === null) { + throw new ServiceException( + 'social_auth.profile.missing_email', + layer: 'service.social_auth', + context: ['provider' => $provider], + ); + } + + // 2 — link to the existing account behind the provider-verified email. + $user = $this->users->findByIdentifier($email); + + // 3 — first sign-in: create the account. + $user ??= $this->createUser($email, $name, $profile['nickname'] ?? null); + + $this->identities->link($provider, $providerUserId, $user->id, $email, $name, $avatar); + + return $user; + } + + // ── Internals ─────────────────────────────────────────────────────────────── + + private function createUser(string $email, ?string $name, ?string $nickname): UserDTO + { + $profile = []; + if (\is_string($name) && trim($name) !== '') { + $parts = preg_split('/\s+/', trim($name), 2) ?: []; + $profile['first_name'] = mb_substr($parts[0] ?? '', 0, 80); + if (($parts[1] ?? '') !== '') { + $profile['last_name'] = mb_substr($parts[1], 0, 80); + } + } + + $dto = new RegisterUserDTO( + username: Username::fromString($this->usernameFor($nickname, $email)), + email: Email::fromString($email), + // Social accounts have no local password — mint an unguessable one. + // The user can set a real one later through the reset flow. + password: 'A1!' . bin2hex(random_bytes(24)), + profile: $profile, + ); + + $verificationToken = $this->users->registerPublic($dto); + + // The provider already verified this mailbox — activate immediately. + try { + $this->users->verifyEmailByToken($verificationToken); + } catch (\Throwable) { + // Non-fatal: the account just stays pending verification. + } + + $user = $this->users->findByIdentifier($email,true); + if ($user === null) { + throw new ServiceException('social_auth.register.lookup_failed', layer: 'service.social_auth'); + } + + return $user; + } + + /** nickname (sanitised) or email local-part, + 4 random hex chars. */ + private function usernameFor(?string $nickname, string $email): string + { + $base = \is_string($nickname) ? (string) preg_replace('/[^A-Za-z0-9._-]/', '', $nickname) : ''; + if (\strlen($base) < 2) { + $base = (string) preg_replace('/[^A-Za-z0-9._-]/', '', explode('@', $email)[0] ?? ''); + } + if (\strlen($base) < 2) { + $base = 'user'; + } + + return strtolower(substr($base, 0, 42)) . '_' . substr(bin2hex(random_bytes(2)), 0, 4); + } + + private function normalizedEmail(mixed $email): ?string + { + if (!\is_string($email)) { + return null; + } + + $email = mb_strtolower(trim($email)); + + return $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false ? $email : null; + } +} diff --git a/plugins/SocialAuth/Infrastructure/Gateways/ProviderTokenGateway.php b/plugins/SocialAuth/Infrastructure/Gateways/ProviderTokenGateway.php new file mode 100644 index 0000000..81367fa --- /dev/null +++ b/plugins/SocialAuth/Infrastructure/Gateways/ProviderTokenGateway.php @@ -0,0 +1,198 @@ + $credentials driver-specific token fields + * @return array{id:string,email:?string,name:?string,nickname:?string,avatar:?string} + */ + public function verify(string $driver, array $credentials): array + { + return match ($driver) { + 'google' => $this->verifyGoogle($credentials), + 'apple' => $this->verifyApple($credentials), + default => throw new GatewayException( + "Token sign-in is not supported for provider [{$driver}].", + layer: 'gateway.social_auth', + ), + }; + } + + // ── Google ────────────────────────────────────────────────────────────────── + + /** @param array $credentials */ + private function verifyGoogle(array $credentials): array + { + $idToken = trim((string) ($credentials['id_token'] ?? '')); + $accessToken = trim((string) ($credentials['access_token'] ?? '')); + + if ($idToken !== '') { + return $this->googleFromTokeninfo($idToken); + } + if ($accessToken !== '') { + return $this->googleFromUserinfo($accessToken); + } + + throw new GatewayException('Google sign-in requires id_token or access_token.', layer: 'gateway.social_auth.google'); + } + + /** @return array{id:string,email:?string,name:?string,nickname:?string,avatar:?string} */ + private function googleFromTokeninfo(string $idToken): array + { + $claims = $this->getJson(self::GOOGLE_TOKENINFO, ['id_token' => $idToken], 'gateway.social_auth.google'); + + // tokeninfo already verified the signature; we must still pin the + // audience to OUR client id or any Google app's token would sign in. + if ($this->googleClientId !== '' && (string) ($claims['aud'] ?? '') !== $this->googleClientId) { + throw new GatewayException('Google id_token audience mismatch.', layer: 'gateway.social_auth.google'); + } + + if ((string) ($claims['sub'] ?? '') === '') { + throw new GatewayException('Google id_token verification failed.', layer: 'gateway.social_auth.google'); + } + + return [ + 'id' => (string) $claims['sub'], + 'email' => $this->verifiedEmail($claims['email'] ?? null, $claims['email_verified'] ?? null), + 'name' => isset($claims['name']) ? (string) $claims['name'] : null, + 'nickname' => null, + 'avatar' => isset($claims['picture']) ? (string) $claims['picture'] : null, + ]; + } + + /** @return array{id:string,email:?string,name:?string,nickname:?string,avatar:?string} */ + private function googleFromUserinfo(string $accessToken): array + { + $response = $this->http->request('GET', self::GOOGLE_USERINFO, [ + 'headers' => ['Authorization' => 'Bearer ' . $accessToken], + ]); + if ($response->failed()) { + throw new GatewayException('Google access_token verification failed.', layer: 'gateway.social_auth.google'); + } + + $info = $response->json(); + if (!\is_array($info) || (string) ($info['sub'] ?? '') === '') { + throw new GatewayException('Google userinfo response was malformed.', layer: 'gateway.social_auth.google'); + } + + return [ + 'id' => (string) $info['sub'], + 'email' => $this->verifiedEmail($info['email'] ?? null, $info['email_verified'] ?? null), + 'name' => isset($info['name']) ? (string) $info['name'] : null, + 'nickname' => null, + 'avatar' => isset($info['picture']) ? (string) $info['picture'] : null, + ]; + } + + // ── Apple ─────────────────────────────────────────────────────────────────── + + /** @param array $credentials */ + private function verifyApple(array $credentials): array + { + $identityToken = trim((string) ($credentials['identity_token'] ?? '')); + if ($identityToken === '') { + throw new GatewayException('Apple sign-in requires identity_token.', layer: 'gateway.social_auth.apple'); + } + + $jwks = $this->getJson(self::APPLE_JWKS, [], 'gateway.social_auth.apple'); + + try { + $claims = (array) JWT::decode($identityToken, JWK::parseKeySet($jwks)); + } catch (\Throwable $e) { + throw new GatewayException( + 'Apple identity_token signature verification failed.', + layer: 'gateway.social_auth.apple', + previous: $e, + ); + } + + if ((string) ($claims['iss'] ?? '') !== self::APPLE_ISSUER) { + throw new GatewayException('Apple identity_token issuer mismatch.', layer: 'gateway.social_auth.apple'); + } + if ($this->appleClientId !== '' && (string) ($claims['aud'] ?? '') !== $this->appleClientId) { + throw new GatewayException('Apple identity_token audience mismatch.', layer: 'gateway.social_auth.apple'); + } + if ((string) ($claims['sub'] ?? '') === '') { + throw new GatewayException('Apple identity_token verification failed.', layer: 'gateway.social_auth.apple'); + } + + // Apple sends the user's name only on FIRST authorization, as a separate + // client-side field — accept it as a hint (it is not security-relevant). + $name = trim((string) ($credentials['name'] ?? '')); + + return [ + 'id' => (string) $claims['sub'], + 'email' => $this->verifiedEmail($claims['email'] ?? null, $claims['email_verified'] ?? null), + 'name' => $name !== '' ? $name : null, + 'nickname' => null, + 'avatar' => null, + ]; + } + + // ── Shared ────────────────────────────────────────────────────────────────── + + /** @return array */ + private function getJson(string $url, array $query, string $layer): array + { + $response = $this->http->get($url, $query); + if ($response->failed()) { + throw new GatewayException('Provider verification endpoint failed.', layer: $layer); + } + + $json = $response->json(); + if (!\is_array($json)) { + throw new GatewayException('Provider verification response was malformed.', layer: $layer); + } + + return $json; + } + + /** Only trust an email the PROVIDER says is verified. */ + private function verifiedEmail(mixed $email, mixed $verified): ?string + { + if (!\is_string($email) || trim($email) === '') { + return null; + } + + // email_verified arrives as bool or the strings "true"/"false". + $isVerified = $verified === true || $verified === 'true' || $verified === 1 || $verified === '1'; + + return $isVerified ? mb_strtolower(trim($email)) : null; + } +} diff --git a/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php b/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php new file mode 100644 index 0000000..8a41b99 --- /dev/null +++ b/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php @@ -0,0 +1,144 @@ +social->redirectUrl($driver)); + } catch (ServiceException) { + return $this->notFound("Unknown or unconfigured social provider [{$driver}]."); + } + } + + public function callback(string $driver): Response + { + $request = $this->resolveRequest(); + + try { + $profile = $this->social->userFromCallback($driver, $request); + $user = $this->login->resolveUser($driver, $this->profileArray($profile)); + } catch (ServiceException $e) { + return $this->socialFailure($e); + } + + if ($request->input('mode') === 'token' || $request->expectsJson()) { + return $this->ok(['user' => $user->toArray(), 'tokens' => $this->issueTokenPair($user)]); + } + + // Web flow: open a platform session and send the browser on its way. + $this->auth->startSession($this->session, $user->id); + + return Response::redirect($this->successRedirect); + } + + public function token(string $driver): Response + { + $request = $this->resolveRequest(); + + try { + $profile = $this->tokens->verify($driver, [ + 'access_token' => (string) $request->input('access_token', ''), + 'id_token' => (string) $request->input('id_token', ''), + 'identity_token' => (string) $request->input('identity_token', ''), + 'name' => (string) $request->input('name', ''), + ]); + $user = $this->login->resolveUser($driver, $profile); + } catch (GatewayException $e) { + return Response::unauthorized($e->getMessage()); + } catch (ServiceException $e) { + return $this->socialFailure($e); + } + + return $this->ok(['user' => $user->toArray(), 'tokens' => $this->issueTokenPair($user)]); + } + + // ── Internals ─────────────────────────────────────────────────────────────── + + /** @return array the old api.md `tokens` shape */ + private function issueTokenPair(UserDTO $user): array + { + $request = $this->resolveRequest(); + $refresh = $this->refreshTokens->issue( + $user->id, + device: $request->header('User-Agent'), + ip: $request->ip(), + ); + + return [ + 'accessToken' => $this->auth->issueJwt($user->id, [], $this->accessTtl), + 'tokenType' => 'Bearer', + 'expiresAt' => time() + $this->accessTtl, + 'refreshToken' => $refresh->token, + 'refreshExpiresAt' => $refresh->expiresAt, + ]; + } + + /** @return array{id:string,email:?string,name:?string,nickname:?string,avatar:?string} */ + private function profileArray(SocialUser $user): array + { + return [ + 'id' => (string) $user->getId(), + 'email' => $user->getEmail(), + 'name' => $user->getName(), + 'nickname' => $user->getNickname(), + 'avatar' => $user->getAvatar(), + ]; + } + + private function socialFailure(ServiceException $e): Response + { + $message = match ($e->getMessage()) { + 'social_auth.profile.missing_email' => + 'This provider account has no verified email — sign in with a provider that shares one, or register first.', + default => 'Social sign-in failed. Please try again.', + }; + + return Response::json(['error' => ['code' => $e->getMessage(), 'message' => $message]], 422); + } +} diff --git a/plugins/SocialAuth/Infrastructure/Persistence/SocialIdentityRepository.php b/plugins/SocialAuth/Infrastructure/Persistence/SocialIdentityRepository.php new file mode 100644 index 0000000..54f93d0 --- /dev/null +++ b/plugins/SocialAuth/Infrastructure/Persistence/SocialIdentityRepository.php @@ -0,0 +1,102 @@ +db->queryOne( + "SELECT user_id FROM {$this->table} WHERE provider = :provider AND provider_user_id = :pid", + ['provider' => $provider, 'pid' => $providerUserId] + ); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to look up social identity', layer: 'repository.social_auth', previous: $e); + } + + return $row !== null ? (string) $row['user_id'] : null; + } + + /** Link (or refresh the snapshot of) a provider account for a user. */ + public function link( + string $provider, + string $providerUserId, + string $userId, + ?string $email, + ?string $name, + ?string $avatar, + ): void { + try { + $this->db->upsert( + $this->table, + [ + 'provider' => $provider, + 'provider_user_id' => $providerUserId, + 'user_id' => $userId, + 'email' => $email !== null ? mb_substr($email, 0, 150) : null, + 'name' => $name !== null ? mb_substr($name, 0, 120) : null, + 'avatar' => $avatar !== null ? mb_substr($avatar, 0, 255) : null, + 'updated_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + ], + ['provider', 'provider_user_id'], + // Refresh the snapshot columns; never move the link to another + // user implicitly (user_id excluded from the update set). + ['email', 'name', 'avatar', 'updated_at'], + ); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to link social identity', layer: 'repository.social_auth', previous: $e); + } + } + + /** + * All linked providers for a user (for a "connected accounts" screen). + * + * @return list + */ + public function listForUser(string $userId): array + { + try { + $rows = $this->db->query( + "SELECT provider, provider_user_id, email, name, avatar, created_at + FROM {$this->table} WHERE user_id = :user_id ORDER BY provider", + ['user_id' => $userId] + ); + } catch (\PDOException $e) { + throw new RepositoryException('Failed to list social identities', layer: 'repository.social_auth', previous: $e); + } + + return array_values($rows); + } + + /** Unlink one provider account from a user. True when a row was removed. */ + public function unlink(string $userId, string $provider): bool + { + try { + return $this->db->execute( + "DELETE FROM {$this->table} WHERE user_id = :user_id AND provider = :provider", + ['user_id' => $userId, 'provider' => $provider] + ) > 0; + } catch (\PDOException $e) { + throw new RepositoryException('Failed to unlink social identity', layer: 'repository.social_auth', previous: $e); + } + } +} diff --git a/plugins/SocialAuth/Provider.php b/plugins/SocialAuth/Provider.php index 1e16672..90280d8 100644 --- a/plugins/SocialAuth/Provider.php +++ b/plugins/SocialAuth/Provider.php @@ -10,8 +10,18 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Cli\CliPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HttpClientPort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort; +use Plugins\Auth\API\Contracts\AuthServiceContract; +use Plugins\Auth\API\Contracts\RefreshTokenServiceContract; +use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\SocialAuth\API\Contracts\SocialAuthServiceContract; use Plugins\SocialAuth\Application\Services\SocialAuthService; +use Plugins\SocialAuth\Application\Services\SocialLoginService; +use Plugins\SocialAuth\Infrastructure\Gateways\ProviderTokenGateway; +use Plugins\SocialAuth\Infrastructure\Http\Controllers\SocialAuthController; +use Plugins\SocialAuth\Infrastructure\Persistence\SocialIdentityRepository; +use Plugins\User\API\Contracts\UserServiceContract; /** * SocialAuth plugin — OAuth1/OAuth2 social login (ported Socialite engine). @@ -30,7 +40,16 @@ public function solves(): string /** @return list */ public function requires(): array { - return []; + // Mirrors module.json "requires": the login bridge maps provider + // profiles onto central users and issues platform credentials via the + // Auth plugin's published contracts; token sign-in verifies against the + // provider over HttpClientPort. + return [ + DatabaseConnectionManagerContract::class, + UserServiceContract::class, + AuthServiceContract::class, + HttpClientPort::class, + ]; } /** @return list */ @@ -47,6 +66,44 @@ public function register(ModuleContainer $container): void baseUrl: env('SOCIAL_AUTH_BASE_URL') ?: '', ); }); + + // Provider-account → user links (central — control-plane table). + $container->bindInternal(SocialIdentityRepository::class, static fn(ModuleContainer $c) => + new SocialIdentityRepository( + $c->make(DatabaseConnectionManagerContract::class)->default(), + ) + ); + + // Find-or-create bridge onto the central identity store. + $container->bindInternal(SocialLoginService::class, static fn(ModuleContainer $c) => + new SocialLoginService( + $c->make(SocialIdentityRepository::class), + $c->make(UserServiceContract::class), + ) + ); + + // Native-SDK token verification (google access_token/id_token, apple + // identity_token against Apple's JWKS). + $container->bindInternal(ProviderTokenGateway::class, static fn(ModuleContainer $c) => + new ProviderTokenGateway( + $c->make(HttpClientPort::class), + googleClientId: env('GOOGLE_CLIENT_ID') ?: '', + appleClientId: env('APPLE_CLIENT_ID') ?: '', + ) + ); + + $container->bindInternal(SocialAuthController::class, static fn(ModuleContainer $c) => + new SocialAuthController( + social: $c->make(SocialAuthServiceContract::class), + login: $c->make(SocialLoginService::class), + tokens: $c->make(ProviderTokenGateway::class), + auth: $c->make(AuthServiceContract::class), + refreshTokens: $c->make(RefreshTokenServiceContract::class), + session: $c->make(SessionPort::class), + accessTtl: (int) (env('AUTH_MOBILE_ACCESS_TTL') ?: 3600), + successRedirect: env('SOCIAL_AUTH_SUCCESS_REDIRECT') ?: '/', + ) + ); } public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void diff --git a/plugins/SocialAuth/database/migrations/2026_07_12_000001_create_social_identities_table.php b/plugins/SocialAuth/database/migrations/2026_07_12_000001_create_social_identities_table.php new file mode 100644 index 0000000..8c58569 --- /dev/null +++ b/plugins/SocialAuth/database/migrations/2026_07_12_000001_create_social_identities_table.php @@ -0,0 +1,50 @@ +hasTable('social_identities')) { + return; + } + + $schema->create('social_identities', static function ($t) { + $t->id(); + $t->string('provider', 32); + $t->string('provider_user_id', 191); + $t->char('user_id', 31); + $t->string('email', 150)->nullable(); + $t->string('name', 120)->nullable(); + $t->string('avatar', 255)->nullable(); + $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); + $t->timestamp('updated_at')->nullable(); + + $t->unique(['provider', 'provider_user_id'], 'uniq_provider_account'); + $t->index(['user_id'], 'idx_user'); + + $t->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); + + $t->engine('InnoDB'); + $t->charset('utf8mb4'); + $t->collation('utf8mb4_0900_ai_ci'); + $t->rowFormat('DYNAMIC'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->dropIfExists('social_identities'); + } +}; diff --git a/plugins/SocialAuth/module.json b/plugins/SocialAuth/module.json index 5139600..4630e28 100644 --- a/plugins/SocialAuth/module.json +++ b/plugins/SocialAuth/module.json @@ -4,20 +4,29 @@ "solves": "auth.social", "type": "module", - "requires": [], + "requires": ["database.management", "user.management", "auth.identity", "http.client"], "exposes": ["Plugins\\SocialAuth\\API\\Contracts\\SocialAuthServiceContract"], - "routes": [], + "routes": [ + { "method": "GET", "path": "/auth/social/{driver}", "handler": "Plugins\\SocialAuth\\Infrastructure\\Http\\Controllers\\SocialAuthController@redirect", "filters": ["throttle:20,1"] }, + { "method": "GET", "path": "/auth/social/{driver}/callback", "handler": "Plugins\\SocialAuth\\Infrastructure\\Http\\Controllers\\SocialAuthController@callback", "filters": ["throttle:20,1"] }, + { "method": "POST", "path": "/auth/social/{driver}/token", "handler": "Plugins\\SocialAuth\\Infrastructure\\Http\\Controllers\\SocialAuthController@token", "filters": ["throttle:10,1"] } + ], "emits": [], "listens": [], + "documentation": "Social sign-in, end to end: GET /auth/social/{driver} redirects to the provider, the callback maps the profile onto a central user (linked identity -> email match -> create) and opens a platform session (web) or returns a JWT+refresh pair (?mode=token). POST /auth/social/{driver}/token verifies a native-SDK token (google access_token/id_token, apple identity_token vs JWKS) for mobile. Links live in central social_identities.", + "config": [ - { "key": "SOCIAL_AUTH_BASE_URL", "type": "string", "required": false }, + { "key": "SOCIAL_AUTH_BASE_URL", "type": "string", "required": false }, + { "key": "SOCIAL_AUTH_SUCCESS_REDIRECT", "type": "string", "required": false }, + { "key": "AUTH_MOBILE_ACCESS_TTL", "type": "int", "required": false }, { "key": "GITHUB_CLIENT_ID", "type": "string", "required": false }, { "key": "GITHUB_CLIENT_SECRET", "type": "string", "required": false }, { "key": "GITHUB_REDIRECT_URI", "type": "string", "required": false }, { "key": "GOOGLE_CLIENT_ID", "type": "string", "required": false }, { "key": "GOOGLE_CLIENT_SECRET", "type": "string", "required": false }, - { "key": "GOOGLE_REDIRECT_URI", "type": "string", "required": false } + { "key": "GOOGLE_REDIRECT_URI", "type": "string", "required": false }, + { "key": "APPLE_CLIENT_ID", "type": "string", "required": false } ] } diff --git a/plugins/Tenancy/API/Contracts/MembershipServiceContract.php b/plugins/Tenancy/API/Contracts/MembershipServiceContract.php index 6f09018..bf78bff 100644 --- a/plugins/Tenancy/API/Contracts/MembershipServiceContract.php +++ b/plugins/Tenancy/API/Contracts/MembershipServiceContract.php @@ -29,6 +29,13 @@ public function myTenants(string $userId): array; */ public function isActiveMember(string $userId, string $tenantId): bool; + /** + * The user's active membership in the tenant — seat AND tenant routable — + * or null when they hold none. Carries the seat's role, so authentication + * can hydrate the user's tenant role from the membership record. + */ + public function activeMember(string $userId, string $tenantId): ?TenantSummary; + /** * Select a tenant: verify active membership + routable tenant, then mint a * tenant-scoped access token (the `tnt` claim). Records `tenant.switch` in diff --git a/plugins/Tenancy/API/DTOs/TenantSummary.php b/plugins/Tenancy/API/DTOs/TenantSummary.php index 9485f10..e27fc0a 100644 --- a/plugins/Tenancy/API/DTOs/TenantSummary.php +++ b/plugins/Tenancy/API/DTOs/TenantSummary.php @@ -18,6 +18,7 @@ public function __construct( public string $slug, public string $role, public string $status, + public ?string $joinedAt = null, ) {} public static function fromMembership(Membership $m): self @@ -28,6 +29,7 @@ public static function fromMembership(Membership $m): self slug: $m->tenantSlug, role: $m->role, status: strtolower($m->status->name), + joinedAt: $m->joinedAt()?->format(\DateTimeInterface::RFC3339), ); } @@ -40,6 +42,7 @@ public function toArray(): array 'slug' => $this->slug, 'role' => $this->role, 'status' => $this->status, + 'joinedAt' => $this->joinedAt, ]; } } diff --git a/plugins/Tenancy/Application/Ports/AuditSink.php b/plugins/Tenancy/Application/Ports/AuditSink.php deleted file mode 100644 index 48017e7..0000000 --- a/plugins/Tenancy/Application/Ports/AuditSink.php +++ /dev/null @@ -1,23 +0,0 @@ - $meta - */ - public function record( - string $action, - ?string $userId = null, - ?string $tenantId = null, - array $meta = [], - ?string $ip = null, - ): void; -} diff --git a/plugins/Tenancy/Application/Services/AuditService.php b/plugins/Tenancy/Application/Services/AuditService.php deleted file mode 100644 index 0c05397..0000000 --- a/plugins/Tenancy/Application/Services/AuditService.php +++ /dev/null @@ -1,49 +0,0 @@ -writer->write($action, $userId, $tenantId, $meta, $ip); - } catch (\Throwable $e) { - // Best-effort: never let an audit write fail the action it records — - // but surface the failure to the log instead of discarding it. - $this->logger->error('Audit trail write failed', [ - 'action' => $action, - 'tenant_id' => $tenantId, - 'user_id' => $userId, - 'exception' => $e::class, - 'message' => $e->getMessage(), - ]); - } - } -} diff --git a/plugins/Tenancy/Application/Services/InvitationService.php b/plugins/Tenancy/Application/Services/InvitationService.php index 2a9cf0c..e6e9635 100644 --- a/plugins/Tenancy/Application/Services/InvitationService.php +++ b/plugins/Tenancy/Application/Services/InvitationService.php @@ -7,7 +7,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use Plugins\Tenancy\API\Contracts\InvitationServiceContract; use Plugins\Tenancy\API\DTOs\InvitationResult; -use Plugins\Tenancy\Application\Ports\AuditSink; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Tenancy\Application\Ports\InvitationStore; use Plugins\Tenancy\Application\Ports\MembershipWriter; use Plugins\Tenancy\Domain\Exceptions\InvalidInvitationException; @@ -28,7 +28,7 @@ final class InvitationService implements InvitationServiceContract public function __construct( private readonly InvitationStore $invitations, private readonly MembershipWriter $memberships, - private readonly AuditSink $audit, + private readonly AuditServiceContract $audit, ) {} public function invite( diff --git a/plugins/Tenancy/Application/Services/MembershipService.php b/plugins/Tenancy/Application/Services/MembershipService.php index ac1292f..07d5af9 100644 --- a/plugins/Tenancy/Application/Services/MembershipService.php +++ b/plugins/Tenancy/Application/Services/MembershipService.php @@ -8,7 +8,7 @@ use Plugins\Tenancy\API\Contracts\MembershipServiceContract; use Plugins\Tenancy\API\DTOs\TenantSelection; use Plugins\Tenancy\API\DTOs\TenantSummary; -use Plugins\Tenancy\Application\Ports\AuditSink; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Tenancy\Application\Ports\MembershipReader; use Plugins\Tenancy\Domain\Exceptions\NotAMemberException; @@ -31,7 +31,7 @@ final class MembershipService implements MembershipServiceContract public function __construct( private readonly MembershipReader $memberships, private readonly AuthServiceContract $auth, - private readonly AuditSink $audit, + private readonly AuditServiceContract $audit, private readonly int $tokenTtl = 3600, ) {} @@ -45,13 +45,22 @@ public function myTenants(string $userId): array public function isActiveMember(string $userId, string $tenantId): bool { - return $this->memberships->find($userId, $tenantId)?->isRoutable() === true; + return $this->activeMember($userId, $tenantId) !== null; } - public function selectTenant(string $userId, string $tenantId, ?string $ip = null): TenantSelection + public function activeMember(string $userId, string $tenantId): ?TenantSummary { $membership = $this->memberships->find($userId, $tenantId); + return $membership !== null && $membership->isRoutable() + ? TenantSummary::fromMembership($membership) + : null; + } + + public function selectTenant(string $userId, string $tenantId, ?string $ip = null): TenantSelection + { + $membership = $this->memberships->find($userId, $tenantId); + if ($membership === null || !$membership->isRoutable()) { $this->audit->record('tenant.switch_denied', $userId, $tenantId, [], $ip); throw NotAMemberException::for($userId, $tenantId); diff --git a/plugins/Tenancy/Application/Services/TenantHostService.php b/plugins/Tenancy/Application/Services/TenantHostService.php index 8dd8072..8e0c6f0 100644 --- a/plugins/Tenancy/Application/Services/TenantHostService.php +++ b/plugins/Tenancy/Application/Services/TenantHostService.php @@ -8,7 +8,7 @@ use Plugins\Tenancy\API\Contracts\TenantHostServiceContract; use Plugins\Tenancy\API\DTOs\HostVerificationInstructions; use Plugins\Tenancy\API\DTOs\HostVerificationResult; -use Plugins\Tenancy\Application\Ports\AuditSink; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Tenancy\Application\Ports\DnsResolver; use Plugins\Tenancy\Application\Ports\TenantHostStore; use Plugins\Tenancy\Domain\Entities\TenantHost; @@ -41,7 +41,7 @@ final class TenantHostService implements TenantHostServiceContract public function __construct( private readonly TenantHostStore $hosts, private readonly DnsResolver $dns, - private readonly AuditSink $audit, + private readonly AuditServiceContract $audit, private readonly TenantHostRegistryContract $registry, /** DNS label the challenge TXT is published under, e.g. "_psp-verify". */ private readonly string $challengePrefix = '_psp-verify', diff --git a/plugins/Tenancy/Domain/Entities/Membership.php b/plugins/Tenancy/Domain/Entities/Membership.php index 3a15b12..87eb8e0 100644 --- a/plugins/Tenancy/Domain/Entities/Membership.php +++ b/plugins/Tenancy/Domain/Entities/Membership.php @@ -20,6 +20,10 @@ */ final class Membership extends Entity { + /** @var array */ + protected array $casts = [ + 'joinedAt' => 'datetime' + ]; public static function of( string $userId, string $tenantId, @@ -30,12 +34,12 @@ public static function of( TenantStatus $tenantStatus, ): self { $m = (new self())->forceFill([ - 'userId' => $userId, - 'tenantId' => $tenantId, - 'tenantName' => $tenantName, - 'tenantSlug' => $tenantSlug, - 'role' => $role, - 'status' => $status, + 'userId' => $userId, + 'tenantId' => $tenantId, + 'tenantName' => $tenantName, + 'tenantSlug' => $tenantSlug, + 'role' => $role, + 'status' => $status, 'tenantStatus' => $tenantStatus, ]); $m->syncOriginal(); @@ -46,13 +50,17 @@ public static function of( /** @param array $row */ public static function fromRow(array $row): self { - $m = (new self())->forceFill([ - 'userId' => (string) $row['user_id'], - 'tenantId' => (string) $row['tenant_id'], - 'tenantName' => (string) ($row['name'] ?? ''), - 'tenantSlug' => (string) ($row['slug'] ?? ''), - 'role' => (string) $row['role'], - 'status' => MembershipStatus::from((int) $row['status']), + + + $m = (new self([ + 'joinedAt' => $row['joined_at'] + ]))->forceFill([ + 'userId' => (string) $row['user_id'], + 'tenantId' => (string) $row['tenant_id'], + 'tenantName' => (string) ($row['name'] ?? ''), + 'tenantSlug' => (string) ($row['slug'] ?? ''), + 'role' => (string) $row['role'], + 'status' => MembershipStatus::from((int) $row['status']), 'tenantStatus' => TenantStatus::from((int) ($row['tenant_status'] ?? TenantStatus::Active->value)), ]); $m->syncOriginal(); @@ -65,4 +73,8 @@ public function isRoutable(): bool { return $this->status->isActive() && $this->tenantStatus->isRoutable(); } + public function joinedAt(): ?\DateTimeImmutable + { + return $this->getDate('joinedAt'); + } } diff --git a/plugins/Tenancy/Infrastructure/Cli/AddTenantHostCommand.php b/plugins/Tenancy/Infrastructure/Cli/AddTenantHostCommand.php index cf271da..57ac653 100644 --- a/plugins/Tenancy/Infrastructure/Cli/AddTenantHostCommand.php +++ b/plugins/Tenancy/Infrastructure/Cli/AddTenantHostCommand.php @@ -9,6 +9,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\Tenancy\API\Contracts\TenantHostServiceContract; +use Plugins\Tenancy\Support\TenantsFile; /** * tenant:host:add — register a hostname for a tenant (seed-friendly). @@ -62,22 +63,36 @@ protected function handle(): int $interactive = $this->isInteractive(); $central = $this->connections->default(); - // Tenant — by flag, else pick from a Select of registered tenants. + // Tenant — by flag, else the default recorded by tenant:create in + // var/tenants.json (validated against the registry; a stale hint is + // dropped), else pick from a Select of registered tenants. if ($id !== '' || $slug !== '') { $tenantId = $this->resolveTenantId($central, $id, $slug); if ($tenantId === null) { $this->error('Tenant not found in the registry.'); return self::FAILURE; } - } elseif ($interactive) { - $tenantId = $this->selectTenant($central); + } else { + $tenantId = null; + $fallback = TenantsFile::defaultTenant(); + if ($fallback !== null) { + $tenantId = $this->resolveTenantId($central, $fallback['tenant_id'], ''); + if ($tenantId !== null) { + $this->info("Using default tenant [{$fallback['slug']}] from " . TenantsFile::path() . '.'); + } else { + TenantsFile::forget($fallback['tenant_id']); + $this->warning('Recorded default tenant no longer exists in the registry — stale entry dropped from var/tenants.json.'); + } + } + if ($tenantId === null && $interactive) { + $tenantId = $this->selectTenant($central); + } if ($tenantId === null) { - $this->error('No tenants in the registry — create one with tenant:create first.'); + $this->error($interactive + ? 'No tenants in the registry — create one with tenant:create first.' + : 'Provide --tenant or --slug (no default tenant recorded in var/tenants.json).'); return self::FAILURE; } - } else { - $this->error('Provide --tenant or --slug .'); - return self::FAILURE; } // Hostname — by flag, else prompt with inline validation. diff --git a/plugins/Tenancy/Infrastructure/Cli/CreateTenantCommand.php b/plugins/Tenancy/Infrastructure/Cli/CreateTenantCommand.php index 9ca4858..15ea60c 100644 --- a/plugins/Tenancy/Infrastructure/Cli/CreateTenantCommand.php +++ b/plugins/Tenancy/Infrastructure/Cli/CreateTenantCommand.php @@ -15,6 +15,7 @@ use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\Tenancy\Infrastructure\Cli\Concerns\ManagesTenantDatabase; use Plugins\Tenancy\Domain\ValueObjects\TenantStatus; +use Plugins\Tenancy\Support\TenantsFile; use Plugins\Tenancy\Support\Token; /** @@ -258,6 +259,15 @@ protected function handle(): int $this->success("Tenant [{$tenantId}] is ACTIVE."); + // Remember the tenant in var/tenants.json (last created = default) so + // tenant:delete / tenant:host:add work without --tenant/--slug. + // Best-effort convenience — never fail a provisioned tenant over it. + try { + TenantsFile::remember($tenantId, $slug, $name); + $this->info("Recorded as default tenant in " . TenantsFile::path() . '.'); + } catch (\Throwable) { + } + return self::SUCCESS; } diff --git a/plugins/Tenancy/Infrastructure/Cli/DeleteTenantCommand.php b/plugins/Tenancy/Infrastructure/Cli/DeleteTenantCommand.php index c6d8aa9..5fc156b 100644 --- a/plugins/Tenancy/Infrastructure/Cli/DeleteTenantCommand.php +++ b/plugins/Tenancy/Infrastructure/Cli/DeleteTenantCommand.php @@ -9,6 +9,7 @@ use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\Tenancy\Domain\Entities\Tenant; use Plugins\Tenancy\Infrastructure\Cli\Concerns\ManagesTenantDatabase; +use Plugins\Tenancy\Support\TenantsFile; /** * tenant:delete — de-provision a tenant (the inverse of tenant:create). @@ -48,15 +49,29 @@ protected function handle(): int $id = (string) $this->option('tenant'); $slug = (string) $this->option('slug'); + // No id/slug → fall back to the default recorded by tenant:create in + // var/tenants.json (the hint is still validated against the registry). + $fromFile = false; if ($id === '' && $slug === '') { - $this->error('Provide --tenant or --slug .'); - return self::FAILURE; + $fallback = TenantsFile::defaultTenant(); + if ($fallback === null) { + $this->error('Provide --tenant or --slug (no default tenant recorded in var/tenants.json).'); + return self::FAILURE; + } + $id = $fallback['tenant_id']; + $fromFile = true; + $this->info("Using default tenant [{$fallback['slug']}] from " . TenantsFile::path() . '.'); } $central = $this->connections->default(); $tenant = $this->resolve($central, $id, $slug); if ($tenant === null) { - $this->error('Tenant not found in the registry.'); + if ($fromFile) { + TenantsFile::forget($id); + $this->error('Recorded default tenant no longer exists in the registry — stale entry dropped from var/tenants.json. Re-run with --tenant or --slug.'); + } else { + $this->error('Tenant not found in the registry.'); + } return self::FAILURE; } @@ -98,6 +113,7 @@ protected function handle(): int try { $central->execute('DELETE FROM tenants WHERE tenant_id = :id', ['id' => $tenant->tenantId]); $this->info('· removed registry row.'); + TenantsFile::forget($tenant->tenantId); } catch (\Throwable $e) { $failed++; $this->error("· could not remove registry row: {$e->getMessage()}"); diff --git a/plugins/Tenancy/Infrastructure/Cli/RememberTenantCommand.php b/plugins/Tenancy/Infrastructure/Cli/RememberTenantCommand.php new file mode 100644 index 0000000..7309ead --- /dev/null +++ b/plugins/Tenancy/Infrastructure/Cli/RememberTenantCommand.php @@ -0,0 +1,101 @@ + # one tenant, by id + * hkm tenant:remember --all # every registered tenant + * hkm tenant:remember # interactive pick (or auto when only one) + */ +final class RememberTenantCommand extends AbstractCommand +{ + public function __construct( + private readonly DatabaseConnectionManagerContract $connections, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->name = 'tenant:remember'; + $this->description = 'Record an existing tenant in var/tenants.json so tenant commands default to it'; + + $this->addOption('tenant', 't', 'Tenant id to remember', acceptsValue: true); + $this->addOption('slug', '', 'Tenant slug to remember', acceptsValue: true); + $this->addOption('all', 'a', 'Remember every registered tenant (last one becomes the default)'); + } + + protected function handle(): int + { + $central = $this->connections->default(); + + if ($this->hasOption('all')) { + $rows = $central->query('SELECT tenant_id, slug, name FROM tenants WHERE deleted_at IS NULL ORDER BY created_at'); + if ($rows === []) { + $this->error('No tenants in the registry — create one with tenant:create first.'); + return self::FAILURE; + } + foreach ($rows as $r) { + TenantsFile::remember((string) $r['tenant_id'], (string) $r['slug'], (string) $r['name']); + $this->info("· remembered [{$r['slug']}] ({$r['tenant_id']})."); + } + $last = end($rows); + $this->success(\count($rows) . ' tenant(s) recorded in ' . TenantsFile::path() . " — default is [{$last['slug']}]."); + return self::SUCCESS; + } + + $id = (string) $this->option('tenant'); + $slug = (string) $this->option('slug'); + + $row = null; + if ($id !== '' || $slug !== '') { + $row = $id !== '' + ? $central->queryOne('SELECT tenant_id, slug, name FROM tenants WHERE tenant_id = :id', ['id' => $id]) + : $central->queryOne('SELECT tenant_id, slug, name FROM tenants WHERE slug = :slug', ['slug' => $slug]); + if ($row === null) { + $this->error('Tenant not found in the registry.'); + return self::FAILURE; + } + } else { + $rows = $central->query('SELECT tenant_id, slug, name FROM tenants WHERE deleted_at IS NULL ORDER BY slug'); + if ($rows === []) { + $this->error('No tenants in the registry — create one with tenant:create first.'); + return self::FAILURE; + } + if (\count($rows) === 1) { + $row = $rows[0]; + } elseif (\function_exists('stream_isatty') && @stream_isatty(\STDIN)) { + $choices = []; + foreach ($rows as $r) { + $choices["{$r['slug']} — {$r['name']} ({$r['tenant_id']})"] = $r; + } + $row = $choices[$this->select('Select the tenant to remember', array_keys($choices))] ?? null; + if ($row === null) { + return self::FAILURE; + } + } else { + $this->error('Several tenants registered — provide --tenant , --slug , or --all.'); + return self::FAILURE; + } + } + + TenantsFile::remember((string) $row['tenant_id'], (string) $row['slug'], (string) $row['name']); + $this->success("Tenant [{$row['slug']}] ({$row['tenant_id']}) recorded as default in " . TenantsFile::path() . '.'); + + return self::SUCCESS; + } +} diff --git a/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php b/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php index 8689480..a67b1a9 100644 --- a/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php +++ b/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php @@ -68,9 +68,14 @@ public function handle(Request $request, callable $next): Response $identifier = $this->identifier ?? ($container?->has(TenantIdentifier::class) ? $container->make(TenantIdentifier::class) : null); - // No container/identifier available — nothing to route. - if ($identifier === null || $container === null) { - return $next($request); + // No identifier bound means Tenancy is not in this request's dependency + // graph — fail loudly instead of silently skipping tenant resolution. + // (Register Tenancy as an essential module so it binds on every request.) + if ($identifier === null) { + throw new \RuntimeException( + 'TenantContextStage: no TenantIdentifier is bound for this request. ' + . 'Ensure the Tenancy module is loaded — register it as an essential module.' + ); } $jar = $container->has(CookieJar::class) ? $container->make(CookieJar::class) : null; @@ -84,6 +89,7 @@ public function handle(Request $request, callable $next): Response $tenantId = $this->rememberedTenant($jar, $request); + if($tenantId === '') { $tenantId = $identifier->identify($request); $fromCookie = false; @@ -95,12 +101,13 @@ public function handle(Request $request, callable $next): Response // claim mode): no tenant to route — keep the central DatabasePort bound and // continue. Without this, resolver->for('') would throw UnknownTenant and // every control-plane/public request (login, OAuth2, marketing) would 404. - if ($tenantId === '') { - return $next($request); - } + // if ($tenantId === '') { + // return $next($request); + // } $resolver = $this->resolver ?? $container->make(TenantConnectionResolverContract::class); + try { $db = $resolver->for($tenantId); } catch (UnknownTenantException) { @@ -187,6 +194,7 @@ private function rememberedTenant(?CookieJar $jar, Request $request): string } $raw = $jar->read($request, self::COOKIE); // decrypted; null if absent/tampered + if ($raw === null) { return ''; } diff --git a/plugins/Tenancy/Infrastructure/Persistence/MembershipRepository.php b/plugins/Tenancy/Infrastructure/Persistence/MembershipRepository.php index e8583fe..f91a585 100644 --- a/plugins/Tenancy/Infrastructure/Persistence/MembershipRepository.php +++ b/plugins/Tenancy/Infrastructure/Persistence/MembershipRepository.php @@ -21,7 +21,7 @@ final class MembershipRepository implements MembershipReader, MembershipWriter { private const SELECT = - 'SELECT ut.user_id, ut.tenant_id, ut.role, ut.status, + 'SELECT ut.user_id, ut.tenant_id, ut.role, ut.joined_at, ut.status, t.name, t.slug, t.status AS tenant_status FROM user_tenants ut JOIN tenants t ON t.tenant_id = ut.tenant_id AND t.deleted_at IS NULL'; diff --git a/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php b/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php index 3fe4f80..e2f2cd0 100644 --- a/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php +++ b/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php @@ -73,6 +73,7 @@ public function for(string $tenantId): DatabasePort $tenant = $this->registry->find($tenantId) ?? $this->reject($name, UnknownTenantException::for($tenantId)); + try { $this->guardStatus($tenant); } catch (TenantUnavailableException $e) { diff --git a/plugins/Tenancy/Provider.php b/plugins/Tenancy/Provider.php index 482fe98..187fd9a 100644 --- a/plugins/Tenancy/Provider.php +++ b/plugins/Tenancy/Provider.php @@ -13,6 +13,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; use Plugins\Auth\API\Contracts\AuthServiceContract; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\Tenancy\API\Contracts\InvitationServiceContract; use Plugins\Tenancy\API\Contracts\MembershipServiceContract; @@ -21,9 +22,7 @@ use Plugins\Tenancy\API\Contracts\TenantHostRegistryContract; use Plugins\Tenancy\API\Contracts\TenantHostServiceContract; use Plugins\Tenancy\API\Contracts\TenantRegistryContract; -use Plugins\Tenancy\Application\Ports\AuditReader; -use Plugins\Tenancy\Application\Ports\AuditSink; -use Plugins\Tenancy\Application\Ports\AuditWriter; +use Plugins\Tenancy\Application\Listeners\AssignTenantMembershipOnUserRegistered; use Plugins\Tenancy\Application\Ports\InvitationStore; use Plugins\Tenancy\Application\Ports\MembershipReader; use Plugins\Tenancy\Application\Ports\MembershipWriter; @@ -31,13 +30,10 @@ use Plugins\Tenancy\Application\Ports\TenantProvisioner; use Plugins\Tenancy\Application\Ports\TenantWriteStore; use Plugins\Tenancy\Application\Ports\TenantHostStore; -use Plugins\Tenancy\Application\Services\AuditService; use Plugins\Tenancy\Application\Services\InvitationService; use Plugins\Tenancy\Application\Services\MembershipService; use Plugins\Tenancy\Application\Services\TenantAdminService; use Plugins\Tenancy\Application\Services\TenantHostService; -use Plugins\Tenancy\Infrastructure\Persistence\AuditTrail; -use Plugins\Tenancy\Infrastructure\Persistence\AuditLogRepository; use Plugins\Tenancy\Infrastructure\Dns\SystemDnsResolver; use Plugins\Tenancy\Infrastructure\Http\Controllers\InvitationController; use Plugins\Tenancy\Infrastructure\Http\Controllers\TenantAdminController; @@ -84,7 +80,7 @@ public function solves(): string public function requires(): array { - return ['database.management', 'auth.identity', 'user.management']; + return ['database.management', 'auth.identity', 'user.management', 'audit.trail']; } public function exposes(): array @@ -146,35 +142,37 @@ public function register(ModuleContainer $container): void // is differs. $container->singleton(TenantIdentifier::class, static function ($c): TenantIdentifier { return match (self::mode()) { - 'host' => new HostTenantIdentifier($c->make(TenantHostRegistryContract::class)), + 'host' => new HostTenantIdentifier($c->make(TenantHostRegistryContract::class)), 'domain' => new DomainTenantIdentifier(self::baseDomains(), self::reservedSubdomains()), - default => new ClaimTenantIdentifier(), + default => new ClaimTenantIdentifier(), }; }); // ── custom-domain management (UI-driven) ───────────────────────────── // Writes the central tenant_hosts table; DNS adapter scans live records // to prove ownership of a domain by the verification token. - $container->bindInternal(TenantHostStore::class, static fn ($c): TenantHostStore => + $container->bindInternal(TenantHostStore::class, static fn($c): TenantHostStore => new TenantHostRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - $container->bindInternal(DnsResolver::class, static fn (): DnsResolver => new SystemDnsResolver()); + $container->bindInternal(DnsResolver::class, static fn(): DnsResolver => new SystemDnsResolver()); - $container->bind(TenantHostServiceContract::class, static fn ($c): TenantHostServiceContract => + $container->bind(TenantHostServiceContract::class, static fn($c): TenantHostServiceContract => new TenantHostService( - hosts: $c->make(TenantHostStore::class), - dns: $c->make(DnsResolver::class), - audit: $c->make(AuditSink::class), - registry: $c->make(TenantHostRegistryContract::class), - challengePrefix: (string) (env('TENANCY_DNS_CHALLENGE_PREFIX') ?: '_psp-verify'), - valuePrefix: (string) (env('TENANCY_DNS_VALUE_PREFIX') ?: 'psp-verify='), + hosts: $c->make(TenantHostStore::class), + dns: $c->make(DnsResolver::class), + audit: $c->make(AuditServiceContract::class), + registry: $c->make(TenantHostRegistryContract::class), + challengePrefix: (string) (env('TENANCY_DNS_CHALLENGE_PREFIX') ?: '_psp-verify'), + valuePrefix: (string) (env('TENANCY_DNS_VALUE_PREFIX') ?: 'psp-verify='), maxHostsPerTenant: self::intEnv('TENANCY_MAX_HOSTS_PER_TENANT', 25), )); - $container->bindInternal(TenantHostController::class, static fn ($c): TenantHostController => + $container->bindInternal(TenantHostController::class, static fn($c): TenantHostController => new TenantHostController($c->make(TenantHostServiceContract::class))); - $container->bind(TenantContextStage::class, static fn ($c): TenantContextStage => + $container->bind( + TenantContextStage::class, + static fn($c): TenantContextStage => new TenantContextStage( $c->make(TenantConnectionResolverContract::class), $c->make(TenantIdentifier::class), @@ -184,30 +182,22 @@ public function register(ModuleContainer $container): void // ── tenant-selection flow (central control plane) ──────────────────── // Membership + audit read/write the CENTRAL connection (user_tenants / // audit_log live in the control-plane DB), pinned via the manager default. - $container->bindInternal(MembershipReader::class, static fn ($c): MembershipReader => + $container->bindInternal(MembershipReader::class, static fn($c): MembershipReader => new MembershipRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - // Write side: repository (persistence seam) behind the audit service. - $container->bindInternal(AuditWriter::class, static fn ($c): AuditWriter => - new AuditTrail($c->make(DatabaseConnectionManagerContract::class)->default())); + // Audit is now owned by the shared Audit plugin (solves audit.trail). + // Tenancy records through its published AuditServiceContract instead of + // writing `audit_log` itself — see requires: ["audit.trail"]. - // Application service owns the best-effort policy consumed by all services. - $container->bindInternal(AuditSink::class, static fn ($c): AuditSink => - new AuditService($c->make(AuditWriter::class), self::optionalLogger($c))); - - // Read/query side of the same central `audit_log` table. - $container->bindInternal(AuditReader::class, static fn ($c): AuditReader => - new AuditLogRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - - $container->bind(MembershipServiceContract::class, static fn ($c): MembershipServiceContract => + $container->bind(MembershipServiceContract::class, static fn($c): MembershipServiceContract => new MembershipService( memberships: $c->make(MembershipReader::class), - auth: $c->make(AuthServiceContract::class), - audit: $c->make(AuditSink::class), - tokenTtl: self::intEnv('TENANCY_TOKEN_TTL', 3600), + auth: $c->make(AuthServiceContract::class), + audit: $c->make(AuditServiceContract::class), + tokenTtl: self::intEnv('TENANCY_TOKEN_TTL', 3600), )); - $container->bindInternal(TenantController::class, static fn ($c): TenantController => + $container->bindInternal(TenantController::class, static fn($c): TenantController => new TenantController($c->make(MembershipServiceContract::class))); // ── tenant administration (control-plane CRUD) ─────────────────────── @@ -215,7 +205,7 @@ public function register(ModuleContainer $container): void // the tenant:create / tenant:delete CLI commands. The service orchestrates // two internal ports: persistence (DatabasePort only) and provisioning // (DDL + template migrations). Both pin to the CENTRAL connection. - $container->bindInternal(TenantWriteStore::class, static fn ($c): TenantWriteStore => + $container->bindInternal(TenantWriteStore::class, static fn($c): TenantWriteStore => new TenantAdminRepository($c->make(DatabaseConnectionManagerContract::class)->default())); $container->bindInternal(TenantProvisioner::class, static function ($c): TenantProvisioner { @@ -224,39 +214,43 @@ public function register(ModuleContainer $container): void return new DdlTenantProvisioner( central: $c->make(DatabaseConnectionManagerContract::class)->default(), templatePath: (is_string($template) && $template !== '') - ? $template - : __DIR__ . '/database/tenant-template', + ? $template + : __DIR__ . '/database/tenant-template', ); }); - $container->bind(TenantAdminServiceContract::class, static fn ($c): TenantAdminServiceContract => + $container->bind(TenantAdminServiceContract::class, static fn($c): TenantAdminServiceContract => new TenantAdminService( - store: $c->make(TenantWriteStore::class), + store: $c->make(TenantWriteStore::class), provisioner: $c->make(TenantProvisioner::class), - registry: $c->make(TenantRegistryContract::class), - crypto: $c->make(EncryptionPort::class), - identity: $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity::class), + registry: $c->make(TenantRegistryContract::class), + crypto: $c->make(EncryptionPort::class), + identity: $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity::class), )); - $container->bindInternal(TenantAdminController::class, static fn ($c): TenantAdminController => + $container->bindInternal(TenantAdminController::class, static fn($c): TenantAdminController => new TenantAdminController($c->make(TenantAdminServiceContract::class))); // ── invitations (email onboarding) ─────────────────────────────────── - $container->bindInternal(InvitationStore::class, static fn ($c): InvitationStore => + $container->bindInternal(InvitationStore::class, static fn($c): InvitationStore => new InvitationRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - $container->bindInternal(MembershipWriter::class, static fn ($c): MembershipWriter => + $container->bindInternal(MembershipWriter::class, static fn($c): MembershipWriter => new MembershipRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - $container->bind(InvitationServiceContract::class, static fn ($c): InvitationServiceContract => + + $container->bindInternal(AssignTenantMembershipOnUserRegistered::class, static fn($c): AssignTenantMembershipOnUserRegistered => + new AssignTenantMembershipOnUserRegistered($c->make(MembershipWriter::class))); + + $container->bind(InvitationServiceContract::class, static fn($c): InvitationServiceContract => new InvitationService( invitations: $c->make(InvitationStore::class), memberships: $c->make(MembershipWriter::class), - audit: $c->make(AuditSink::class), + audit: $c->make(AuditServiceContract::class), )); // ── HTTP boundary for the invitation flow ──────────────────────────── - $container->bindInternal(InvitationController::class, static fn ($c): InvitationController => + $container->bindInternal(InvitationController::class, static fn($c): InvitationController => new InvitationController( $c->make(InvitationServiceContract::class), $c->make(UserServiceContract::class), @@ -307,11 +301,15 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke (new \Plugins\Database\Provider())->register($c); $c->setScope((new \Plugins\Crypto\Provider())->solves()); (new \Plugins\Crypto\Provider())->register($c); + // Audit publishes AuditServiceContract, which the Tenancy services + // built below (TenantHostService, …) now depend on. + $c->setScope((new \Plugins\Audit\Provider())->solves()); + (new \Plugins\Audit\Provider())->register($c); $c->setScope('tenancy.routing'); (new self())->register($c); $connections = $c->make(DatabaseConnectionManagerContract::class); - $crypto = $c->make(EncryptionPort::class); + $crypto = $c->make(EncryptionPort::class); $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\CreateTenantCommand($connections, $crypto)); $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\MigrateTenantsCommand( @@ -320,6 +318,7 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke $crypto, )); $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\DeleteTenantCommand($connections)); + $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\RememberTenantCommand($connections)); $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\AddTenantHostCommand( $c->make(TenantHostServiceContract::class), $connections, diff --git a/plugins/Tenancy/README.md b/plugins/Tenancy/README.md index 0b6baf2..d73af61 100644 --- a/plugins/Tenancy/README.md +++ b/plugins/Tenancy/README.md @@ -137,6 +137,35 @@ The tenant template lives in `database/tenant-template/`. Override with applied batch for fleet-wide drift visibility. A failing tenant is skipped, not fatal — the run is resumable. +### `var/tenants.json` — default tenant for the CLI + +A successful `tenant:create` records the tenant in the project's +`var/tenants.json` (`Plugins\Tenancy\Support\TenantsFile`) and makes it the +**default** (last created wins). Commands that target one tenant then work +without `--tenant`/`--slug`: + +``` +hkm tenant:create --name="Acme" --slug=acme ... # recorded as default +hkm tenant:host:add --host=acme.localhost --verified # → default tenant +hkm tenant:delete --drop-database # → default tenant +``` + +Tenants provisioned BEFORE this existed (or after a `var/` wipe — it is +disposable) are backfilled with `tenant:remember`: + +``` +hkm tenant:remember # only one tenant registered → recorded; else interactive pick +hkm tenant:remember --slug=acme # one tenant by slug (becomes the default) +hkm tenant:remember --all # every registered tenant (last = default) +``` + +The file is a convenience HINT only — the central `tenants` table stays the +source of truth. Every command re-validates the recorded id against the +registry and silently drops a stale entry (e.g. a tenant deleted elsewhere). +`tenant:delete` also removes the entry on success; the default falls back to +the last remaining recorded tenant. `tenant:migrate` needs no id either way — +it fleet-migrates every active tenant by default. + ## Isolation guarantees - **Fail closed.** Unknown / suspended / deleted / unreachable tenant → throw. diff --git a/plugins/Tenancy/Support/TenantsFile.php b/plugins/Tenancy/Support/TenantsFile.php new file mode 100644 index 0000000..2435db4 --- /dev/null +++ b/plugins/Tenancy/Support/TenantsFile.php @@ -0,0 +1,120 @@ +", + * "tenants": [ { "tenant_id": "…", "slug": "acme", "name": "Acme Inc" } ] } + * + * `default` is the most recently created tenant (last create wins). + */ +final class TenantsFile +{ + public static function path(): string + { + return Paths::var('tenants.json'); + } + + /** Upsert a tenant and make it the default (last created wins). */ + public static function remember(string $tenantId, string $slug, string $name): void + { + $data = self::read(); + $data['tenants'] = array_values(array_filter( + $data['tenants'], + static fn (array $t): bool => $t['tenant_id'] !== $tenantId, + )); + $data['tenants'][] = ['tenant_id' => $tenantId, 'slug' => $slug, 'name' => $name]; + $data['default'] = $tenantId; + self::write($data); + } + + /** Drop a tenant; the default falls back to the last remaining entry. */ + public static function forget(string $tenantId): void + { + $data = self::read(); + $data['tenants'] = array_values(array_filter( + $data['tenants'], + static fn (array $t): bool => $t['tenant_id'] !== $tenantId, + )); + if ($data['default'] === $tenantId) { + $last = end($data['tenants']); + $data['default'] = $last === false ? '' : $last['tenant_id']; + } + self::write($data); + } + + /** @return list */ + public static function all(): array + { + return self::read()['tenants']; + } + + /** + * The tenant other commands should act on when none is named: the recorded + * default when still present, else the only entry, else null. + * + * @return array{tenant_id: string, slug: string, name: string}|null + */ + public static function defaultTenant(): ?array + { + $data = self::read(); + foreach ($data['tenants'] as $t) { + if ($t['tenant_id'] === $data['default']) { + return $t; + } + } + + return \count($data['tenants']) === 1 ? $data['tenants'][0] : null; + } + + /** @return array{tenants: list, default: string} */ + private static function read(): array + { + $raw = @file_get_contents(self::path()); + $data = \is_string($raw) ? json_decode($raw, true) : null; + + $tenants = []; + foreach ((\is_array($data) ? ($data['tenants'] ?? []) : []) as $t) { + if (\is_array($t) && \is_string($t['tenant_id'] ?? null) && $t['tenant_id'] !== '') { + $tenants[] = [ + 'tenant_id' => $t['tenant_id'], + 'slug' => \is_string($t['slug'] ?? null) ? $t['slug'] : '', + 'name' => \is_string($t['name'] ?? null) ? $t['name'] : '', + ]; + } + } + + return [ + 'tenants' => $tenants, + 'default' => \is_array($data) && \is_string($data['default'] ?? null) ? $data['default'] : '', + ]; + } + + /** @param array{tenants: list, default: string} $data */ + private static function write(array $data): void + { + $path = self::path(); + $dir = \dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + @file_put_contents( + $path, + json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) . "\n", + \LOCK_EX, + ); + } +} diff --git a/plugins/Tenancy/module.json b/plugins/Tenancy/module.json index 433bbc6..63b572f 100644 --- a/plugins/Tenancy/module.json +++ b/plugins/Tenancy/module.json @@ -1,99 +1,242 @@ { - "name": "tenancy", - "version": "1.0.0", - "solves": "tenancy.routing", - "type": "module", - "description": "Multi-tenant control plane: tenant registry + per-tenant database routing. Identifies the tenant by TENANCY_MODE — 'claim' (default: authenticated Identity.tenantId, the SaaS/JWT model) or 'domain' (the Host sub-domain, the anonymous storefront model) — then rebinds an isolated tenant DatabasePort into the request container so every repository talks to the correct tenant database. Database-per-tenant isolation (MySQL/PostgreSQL/SQLite) on top of plugins/Database ConnectionManager.", - - "requires": ["tenant.settings","database.management", "auth.identity", "user.management", "view.rendering","validation.rules"], - - "views": "resources/views", - - "exposes": [ - "TenantRegistryContract", - "TenantHostRegistryContract", - "TenantHostServiceContract", - "TenantConnectionResolverContract", - "MembershipServiceContract", - "InvitationServiceContract", - "TenantAdminServiceContract" - ], - - "routes": [ - { "method": "GET", "path": "/tenants", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@index", - "requires": ["http.pageflow"] }, - { "method": "GET", "path": "/tenants/manage", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@manage", - "requires": ["http.pageflow"] }, - { "method": "GET", "path": "/tenants/create", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@create", - "requires": ["http.pageflow"] }, - { "method": "GET", "path": "/tenants/{tenantId}/edit", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@edit", - "requires": ["http.pageflow"] }, - { "method": "GET", "path": "/tenant/hosts", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@hosts", - "requires": ["http.pageflow"] }, - - { "method": "GET", "path": "/ajx/admin/tenants", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@index", - "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/admin/tenants", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@store", - "filters": ["auth"] }, - { "method": "GET", "path": "/ajx/admin/tenants/{tenantId}", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@show", - "filters": ["auth"] }, - { "method": "PUT", "path": "/ajx/admin/tenants/{tenantId}", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@update", - "filters": ["auth"] }, - { "method": "DELETE", "path": "/ajx/admin/tenants/{tenantId}", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@destroy", - "filters": ["auth"] }, - - { "method": "GET", "path": "/ajx/me/tenants", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantController@mine", - "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/tenants/{tenantId}/select", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantController@select", - "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/invitations/accept", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\InvitationController@accept", - "filters": ["auth"] }, - - { "method": "GET", "path": "/ajx/tenant/hosts", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@index", - "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/tenant/hosts", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@store", - "filters": ["auth"] }, - { "method": "GET", "path": "/ajx/tenant/hosts/{hostId}/instructions", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@instructions", - "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/tenant/hosts/{hostId}/verify", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@verify", - "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/tenant/hosts/{hostId}/primary", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@makePrimary", - "filters": ["auth"] }, - { "method": "DELETE", "path": "/ajx/tenant/hosts/{hostId}", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@destroy", - "filters": ["auth"] } - ], - - "config": [ - { "key": "TENANCY_MODE", "type": "string", "required": false }, - { "key": "TENANCY_DNS_CHALLENGE_PREFIX", "type": "string", "required": false }, - { "key": "TENANCY_DNS_VALUE_PREFIX", "type": "string", "required": false }, - { "key": "TENANCY_MAX_HOSTS_PER_TENANT", "type": "int", "required": false }, - { "key": "TENANCY_BASE_DOMAINS", "type": "string", "required": false }, - { "key": "TENANCY_RESERVED_SUBDOMAINS", "type": "string", "required": false }, - { "key": "TENANCY_REGISTRY_TTL", "type": "int", "required": false }, - { "key": "TENANCY_BREAKER_THRESHOLD", "type": "int", "required": false }, - { "key": "TENANCY_BREAKER_COOLDOWN", "type": "int", "required": false }, - { "key": "TENANCY_BREAKER_WINDOW", "type": "int", "required": false }, - { "key": "TENANCY_TEMPLATE_PATH", "type": "string", "required": false }, - { "key": "TENANCY_TOKEN_TTL", "type": "int", "required": false } - ] + "name": "tenancy", + "version": "1.0.0", + "solves": "tenancy.routing", + "type": "module", + "description": "Multi-tenant control plane: tenant registry + per-tenant database routing. Identifies the tenant by TENANCY_MODE \u2014 'claim' (default: authenticated Identity.tenantId, the SaaS/JWT model) or 'domain' (the Host sub-domain, the anonymous storefront model) \u2014 then rebinds an isolated tenant DatabasePort into the request container so every repository talks to the correct tenant database. Database-per-tenant isolation (MySQL/PostgreSQL/SQLite) on top of plugins/Database ConnectionManager.", + "requires": [ + "tenant.settings", + "database.management", + "auth.identity", + "user.management", + "view.rendering", + "validation.rules", + "audit.trail" + ], + "views": "resources/views", + "exposes": [ + "TenantRegistryContract", + "TenantHostRegistryContract", + "TenantHostServiceContract", + "TenantConnectionResolverContract", + "MembershipServiceContract", + "InvitationServiceContract", + "TenantAdminServiceContract" + ], + "routes": [ + { + "method": "GET", + "path": "/tenants", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@index", + "requires": [ + "http.pageflow" + ] + }, + { + "method": "GET", + "path": "/tenants/manage", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@manage", + "requires": [ + "http.pageflow" + ] + }, + { + "method": "GET", + "path": "/tenants/create", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@create", + "requires": [ + "http.pageflow" + ] + }, + { + "method": "GET", + "path": "/tenants/{tenantId}/edit", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@edit", + "requires": [ + "http.pageflow" + ] + }, + { + "method": "GET", + "path": "/tenant/hosts", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@hosts", + "requires": [ + "http.pageflow" + ] + }, + { + "method": "GET", + "path": "/ajx/admin/tenants", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@index", + "filters": [ + "auth" + ] + }, + { + "method": "POST", + "path": "/ajx/admin/tenants", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@store", + "filters": [ + "auth" + ] + }, + { + "method": "GET", + "path": "/ajx/admin/tenants/{tenantId}", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@show", + "filters": [ + "auth" + ] + }, + { + "method": "PUT", + "path": "/ajx/admin/tenants/{tenantId}", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@update", + "filters": [ + "auth" + ] + }, + { + "method": "DELETE", + "path": "/ajx/admin/tenants/{tenantId}", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@destroy", + "filters": [ + "auth" + ] + }, + { + "method": "GET", + "path": "/ajx/me/tenants", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantController@mine", + "filters": [ + "auth" + ] + }, + { + "method": "POST", + "path": "/ajx/tenants/{tenantId}/select", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantController@select", + "filters": [ + "auth" + ] + }, + { + "method": "POST", + "path": "/ajx/invitations/accept", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\InvitationController@accept", + "filters": [ + "auth" + ] + }, + { + "method": "GET", + "path": "/ajx/tenant/hosts", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@index", + "filters": [ + "auth" + ] + }, + { + "method": "POST", + "path": "/ajx/tenant/hosts", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@store", + "filters": [ + "auth" + ] + }, + { + "method": "GET", + "path": "/ajx/tenant/hosts/{hostId}/instructions", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@instructions", + "filters": [ + "auth" + ] + }, + { + "method": "POST", + "path": "/ajx/tenant/hosts/{hostId}/verify", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@verify", + "filters": [ + "auth" + ] + }, + { + "method": "POST", + "path": "/ajx/tenant/hosts/{hostId}/primary", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@makePrimary", + "filters": [ + "auth" + ] + }, + { + "method": "DELETE", + "path": "/ajx/tenant/hosts/{hostId}", + "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@destroy", + "filters": [ + "auth" + ] + } + ], + "config": [ + { + "key": "TENANCY_MODE", + "type": "string", + "required": false + }, + { + "key": "TENANCY_DNS_CHALLENGE_PREFIX", + "type": "string", + "required": false + }, + { + "key": "TENANCY_DNS_VALUE_PREFIX", + "type": "string", + "required": false + }, + { + "key": "TENANCY_MAX_HOSTS_PER_TENANT", + "type": "int", + "required": false + }, + { + "key": "TENANCY_BASE_DOMAINS", + "type": "string", + "required": false + }, + { + "key": "TENANCY_RESERVED_SUBDOMAINS", + "type": "string", + "required": false + }, + { + "key": "TENANCY_REGISTRY_TTL", + "type": "int", + "required": false + }, + { + "key": "TENANCY_BREAKER_THRESHOLD", + "type": "int", + "required": false + }, + { + "key": "TENANCY_BREAKER_COOLDOWN", + "type": "int", + "required": false + }, + { + "key": "TENANCY_BREAKER_WINDOW", + "type": "int", + "required": false + }, + { + "key": "TENANCY_TEMPLATE_PATH", + "type": "string", + "required": false + }, + { + "key": "TENANCY_TOKEN_TTL", + "type": "int", + "required": false + } + ] } diff --git a/plugins/User/API/Contracts/UserServiceContract.php b/plugins/User/API/Contracts/UserServiceContract.php index 9131999..d6380f3 100644 --- a/plugins/User/API/Contracts/UserServiceContract.php +++ b/plugins/User/API/Contracts/UserServiceContract.php @@ -10,6 +10,7 @@ use Plugins\User\API\DTOs\UserDTO; use Plugins\User\API\DTOs\UserPage; use Plugins\User\API\DTOs\VerifyEmailDTO; +use Plugins\User\API\DTOs\VerifyEmailResult; /** * Published contract for the user.management domain. Other modules depend on @@ -17,6 +18,12 @@ */ interface UserServiceContract { + /** verifyEmailByToken() outcomes. */ + public const VERIFY_OK = 'verified'; // token consumed, email now verified + public const VERIFY_ALREADY = 'already_verified'; // valid token, account was already verified + public const VERIFY_EXPIRED = 'expired'; // token MATCHED a user but is past its TTL + public const VERIFY_INVALID = 'invalid'; // unknown / consumed token — no match + /** Keyset-paginated listing (admin-only). */ public function list(ListUsersQuery $query): UserPage; @@ -32,15 +39,33 @@ public function registerPublic(RegisterUserDTO $dto): string; /** * Confirm an email from the PUBLIC (unauthenticated) verification link. The - * token is matched by its stored hash and must be unexpired + unconsumed. - * Returns false on any miss. No identity required — this is the pre-login flow. + * token is matched by its stored hash and must be unexpired. Returns one of + * the VERIFY_* constants: + * - VERIFY_OK the token was valid and the email is now verified + * - VERIFY_ALREADY the token was valid but the account was already verified + * (safe to disclose — only the inbox owner holds the token) + * - VERIFY_EXPIRED the token MATCHED a pending user but is past its TTL + * (safe to disclose for the same reason — steer to resend) + * - VERIFY_INVALID unknown / consumed token — no match (generic on purpose) + * No identity required — this is the pre-login flow. Returns a result whose + * ->email is set ONLY for the matched cases (expired / already verified), so + * the caller can bind a resend cookie to that address. + */ + public function verifyEmailByToken(string $token): VerifyEmailResult; + + /** + * PUBLIC (unauthenticated) re-issue of an email-verification token. Re-arms a + * fresh token for an UNVERIFIED account and returns the plaintext for the + * caller to email. Returns null when there is nothing to send (unknown email + * OR already verified) — the caller MUST respond identically either way so a + * request never reveals whether an address is registered or its state. */ - public function verifyEmailByToken(string $token): bool; + public function resendVerification(string $email): ?string; - public function find(string $id): ?UserDTO; + public function find(string $id, bool $checkMembership = false): ?UserDTO; /** Look up a user by username OR email (no credential check). Null if absent. */ - public function findByIdentifier(string $identifier): ?UserDTO; + public function findByIdentifier(string $identifier, bool $checkMembership = false): ?UserDTO; /** * Force-set a user's password (password-reset flow — token-authorized, so it @@ -74,10 +99,10 @@ public function findByRememberToken(string $token): ?UserDTO; * PLAINTEXT once (goes into the recaller cookie). Rotating on every use means * a stolen cookie is invalidated the moment the real user next authenticates. */ - public function cycleRememberToken(string $userId): string; + public function cycleRememberToken(string $userId, bool $checkMembership = false): string; /** Clear a user's remember-token (logout) so outstanding recaller cookies die. */ - public function clearRememberToken(string $userId): void; + public function clearRememberToken(string $userId, bool $checkMembership = false): void; - public function delete(string $id): bool; + public function delete(string $id, bool $checkMembership = false): bool; } diff --git a/plugins/User/API/DTOs/RegisterUserDTO.php b/plugins/User/API/DTOs/RegisterUserDTO.php index ee8b057..c553e76 100644 --- a/plugins/User/API/DTOs/RegisterUserDTO.php +++ b/plugins/User/API/DTOs/RegisterUserDTO.php @@ -10,6 +10,7 @@ use Plugins\User\Domain\ValueObjects\PasswordPolicy; use Plugins\User\Domain\ValueObjects\Username; use Plugins\Validation\AbstractDto; +use Plugins\Validation\Validator; /** * Validated registration input. rules() carry the field SHAPE (mirrored by the @@ -41,22 +42,23 @@ public function __construct( * @var array */ public array $profile = [], - ) {} + ) { + } /** Profile keys accepted at signup — never trust arbitrary request input. */ private const PROFILE_FIELDS = [ 'first_name' => 80, - 'last_name' => 80, - 'phone' => 15, - 'timezone' => 50, - 'locale' => 5, + 'last_name' => 80, + 'phone' => 15, + 'timezone' => 50, + 'locale' => 5, ]; protected static function rules(): array { return [ 'username' => 'required|string|min:5|max:50|regex:/^[A-Za-z0-9._-]+$/', - 'email' => 'required|string|email|max:150', + 'email' => 'required|string|email|max:150', 'password' => 'required|string', ]; } @@ -64,29 +66,52 @@ protected static function rules(): array protected static function messages(): array { return [ - 'username.min' => 'Username must be between 5 and 50 characters.', - 'username.max' => 'Username must be between 5 and 50 characters.', + 'username.min' => 'Username must be between 5 and 50 characters.', + 'username.max' => 'Username must be between 5 and 50 characters.', 'username.regex' => 'Username may only contain letters, digits, dot, underscore and hyphen.', - 'email.email' => 'Email is not a valid address.', - 'email.max' => 'Email must be 150 characters or fewer.', + 'email.email' => 'Email is not a valid address.', + 'email.max' => 'Email must be 150 characters or fewer.', + ]; + } + + /** + * Validation for the OPTIONAL profile fields. Every rule is `nullable`, so + * an absent field passes; a present one must match. Uses only CORE Validator + * rules (no CommonRules registration needed). Keys mirror PROFILE_FIELDS. + * + * @return array + */ + private static function profileRules(): array + { + return [ + 'first_name' => "nullable|string|max:80|regex:/^[\\p{L}\\p{M} .,'\\-]+$/u", + 'last_name' => "nullable|string|max:80|regex:/^[\\p{L}\\p{M} .,'\\-]+$/u", + 'phone' => 'nullable|string|max:15|regex:/^[0-9+()\\s-]+$/', + 'timezone' => 'nullable|string|timezone', + 'locale' => 'nullable|string|max:5|regex:/^[A-Za-z]{2}([_-][A-Za-z]{2})?$/', ]; } public static function fromRequest(Request $request): self { - // Shape errors + password-strength errors combine into one 422. + // Shape errors + password-strength errors + profile-field errors all + // combine into one 422. Profile is validated on the ASSEMBLED array so a + // derived full_name → first_name/last_name split is checked too. + $profile = self::profileFrom($request); + $errors = static::collectErrors($request->all()); $errors += PasswordPolicy::validate((string) $request->input('password', '')); + $errors += Validator::make($profile, self::profileRules())->errors(); if ($errors !== []) { throw new ValidationException($errors); } return new self( username: Username::fromString(trim((string) $request->input('username', ''))), - email: Email::fromString(trim((string) $request->input('email', ''))), + email: Email::fromString(trim((string) $request->input('email', ''))), password: (string) $request->input('password', ''), tenantId: (string) ($request->attribute('tenant') ?? ''), - profile: self::profileFrom($request), + profile: $profile, ); } @@ -101,6 +126,28 @@ private static function profileFrom(Request $request): array } } + // Some clients send a single "full name" instead of first/last. Split it + // (first token → first_name, remainder → last_name) to fill only the + // parts not already supplied explicitly — explicit fields always win. + $full = trim((string) $request->input( + 'full_name', + (string) $request->input( + 'fullname', + (string) $request->input('name', '') + ) + )); + if ($full !== '') { + $parts = preg_split('/\s+/', $full, 2) ?: []; + $first = trim($parts[0] ?? ''); + $last = trim($parts[1] ?? ''); + if ($first !== '' && !isset($profile['first_name'])) { + $profile['first_name'] = mb_substr($first, 0, self::PROFILE_FIELDS['first_name']); + } + if ($last !== '' && !isset($profile['last_name'])) { + $profile['last_name'] = mb_substr($last, 0, self::PROFILE_FIELDS['last_name']); + } + } + return $profile; } } diff --git a/plugins/User/API/DTOs/UserDTO.php b/plugins/User/API/DTOs/UserDTO.php index 8894eec..ad9d324 100644 --- a/plugins/User/API/DTOs/UserDTO.php +++ b/plugins/User/API/DTOs/UserDTO.php @@ -18,15 +18,22 @@ public function __construct( public string $email, public bool $emailVerified, public string $createdAt, + public array $roles = [], + public ?string $tenantId = null, + public ?string $joinedAt = null, ) {} public static function fromEntity(User $user): self { + $roles = $user->getMembership()?->role; return new self( id: $user->id(), username: $user->username(), email: $user->email(), emailVerified: $user->isEmailVerified(), + roles: $roles !== null ? [$roles] : [], + joinedAt: $user->getMembership()?->joinedAt, + tenantId: $user->getMembership()?->tenantId, createdAt: $user->createdAt()->format(\DateTimeInterface::RFC3339), ); } @@ -40,6 +47,9 @@ public function toArray(): array 'email' => $this->email, 'emailVerified' => $this->emailVerified, 'createdAt' => $this->createdAt, + 'roles' => $this->roles, + 'joinedAt' => $this->joinedAt, + 'tenantId' => $this->tenantId, ]; } } diff --git a/plugins/User/API/DTOs/VerifyEmailResult.php b/plugins/User/API/DTOs/VerifyEmailResult.php new file mode 100644 index 0000000..56659d5 --- /dev/null +++ b/plugins/User/API/DTOs/VerifyEmailResult.php @@ -0,0 +1,29 @@ +outbox->pending($limit) as $row) { + try { + $payload = json_decode((string) $row['payload'], true, 512, JSON_THROW_ON_ERROR); + + $this->eventBus->dispatch(new GenericIntegrationEvent( + name: (string) $row['event_name'], + version: (string) $row['event_version'], + payload: is_array($payload) ? $payload : [], + )); + + $this->outbox->markDispatched((int) $row['id']); + $dispatched++; + } catch (\Throwable $e) { + $this->outbox->markFailed((int) $row['id'], (int) $row['attempts'] + 1, $e->getMessage()); + } + } + + return $dispatched; + } +} diff --git a/plugins/User/Application/Services/UserService.php b/plugins/User/Application/Services/UserService.php index 0178ee7..cb8abd7 100644 --- a/plugins/User/Application/Services/UserService.php +++ b/plugins/User/Application/Services/UserService.php @@ -8,12 +8,14 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Events\Contracts\DomainEventContract; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\Contracts\IntegrationEventContract; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\DomainEventCollector; +use AlfacodeTeam\PhpServicePlatform\Kernel\Events\EventBus; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\SecurityException; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ServiceException; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HashingPort; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; +use Plugins\Tenancy\API\Contracts\MembershipServiceContract; use Plugins\User\API\Contracts\UserServiceContract; use Plugins\User\API\DTOs\ListUsersQuery; use Plugins\User\API\DTOs\RegisterUserDTO; @@ -21,6 +23,7 @@ use Plugins\User\API\DTOs\UserDTO; use Plugins\User\API\DTOs\UserPage; use Plugins\User\API\DTOs\VerifyEmailDTO; +use Plugins\User\API\DTOs\VerifyEmailResult; use Plugins\User\API\IntegrationEvents\UserDeletedIntegrationEvent; use Plugins\User\API\IntegrationEvents\UserRegisteredIntegrationEvent; use Plugins\User\API\IntegrationEvents\UserUpdatedIntegrationEvent; @@ -31,7 +34,7 @@ use Plugins\User\Domain\Events\UserDeletedDomainEvent; use Plugins\User\Domain\Events\UserRegisteredDomainEvent; use Plugins\User\Domain\Events\UserUpdatedDomainEvent; -use Plugins\User\Infrastructure\Audit\AuditLogger; +use Plugins\Audit\API\Contracts\AuditServiceContract; /** * UserService — orchestrates the user.management domain. @@ -55,19 +58,23 @@ final class UserService implements UserServiceContract /** Lockout policy. */ private const MAX_LOGIN_FAILURES = 5; - private const LOCKOUT_WINDOW = 900; // seconds (15 min) + private const LOCKOUT_WINDOW = 900; // seconds (15 min) public function __construct( private readonly UserStore $repository, private readonly TransactionManager $transaction, private readonly DomainEventCollector $collector, private readonly OutboxPort $outbox, + private readonly EventBus $eventBus, private readonly HashingPort $hasher, private readonly Identity $identity, private readonly CachePort $cache, - private readonly AuditLogger $audit, + private readonly AuditServiceContract $audit, private readonly ?BreachChecker $breachChecker = null, - ) {} + private readonly ?string $tenantId = null, + private readonly ?MembershipServiceContract $membership = null, + ) { + } public function list(ListUsersQuery $query): UserPage { @@ -77,9 +84,9 @@ public function list(ListUsersQuery $query): UserPage [$users, $hasMore] = $this->repository->paginate($query); return new UserPage( - items: array_map(static fn(User $u): UserDTO => UserDTO::fromEntity($u), $users), + items: array_map(static fn(User $u): UserDTO => UserDTO::fromEntity($u), $users), hasMore: $hasMore, - limit: $query->limit, + limit: $query->limit, ); } @@ -105,7 +112,7 @@ public function register(RegisterUserDTO $dto): UserDTO */ public function registerPublic(RegisterUserDTO $dto): string { - [, $token] = $this->provision($dto); + [$_, $token] = $this->provision($dto); return $token; } @@ -116,35 +123,44 @@ public function registerPublic(RegisterUserDTO $dto): string * false on any miss (unknown/expired/consumed) so a forged token reveals * nothing. */ - public function verifyEmailByToken(string $token): bool + public function verifyEmailByToken(string $token): VerifyEmailResult { if ($token === '') { - return false; + return VerifyEmailResult::invalid(); } $user = $this->repository->findByVerificationTokenHash(hash('sha256', $token)); if ($user === null) { - $this->audit->record('user.email_verify.token_miss', []); - return false; + $this->audit->record('user.email_verify.token_miss'); + return VerifyEmailResult::invalid(); } + // The token HASH matched a real pending user — so possession is already + // proven. If it is merely expired we can safely say so (and steer the + // holder to resend) without aiding enumeration: a forged/unknown token + // never reaches this branch (it fails the hash lookup above → INVALID). $expiresAt = $user->emailVerificationExpiresAt(); if ($expiresAt === null || $expiresAt < new \DateTimeImmutable()) { - $this->audit->record('user.email_verify.token_expired', ['userId' => $user->id()]); - return false; + $this->audit->record('user.email_verify.token_expired', userId: $user->id()); + return VerifyEmailResult::expired($user->email()); + } + + // Proof of control established: a valid, unexpired token. Only now — when + // the caller demonstrably holds the emailed secret — is it safe to + // disclose that the account is already verified (the open resend form + // never reveals this). The token hash survives verification precisely so + // a second click resolves here instead of a confusing "invalid link". + if ($user->isEmailVerified()) { + $this->audit->record('user.email_verify.already', userId: $user->id()); + return VerifyEmailResult::already($user->email()); } $this->collector->beginCollection(); $this->transaction->begin(); try { $user->verifyEmail(); - if (!$user->commitChanges()) { - $this->transaction->rollback(); - $this->collector->discard(); - return true; // already verified — idempotent success - } - - $this->flushEvents($user); + $user->commitChanges(); + $pending = $this->flushEvents($user); $this->repository->update($user); $this->transaction->commit(); } catch (\Throwable $e) { @@ -154,9 +170,56 @@ public function verifyEmailByToken(string $token): bool } $this->collector->release(); - $this->audit->record('user.email_verified', ['userId' => $user->id()]); + $this->deliver($pending); + $this->audit->record('user.email_verified', userId: $user->id()); - return true; + return VerifyEmailResult::ok(); + } + + /** + * PUBLIC re-issue of a verification token. Enumeration-safe: returns null — + * with no observable difference — when the email is unknown OR already + * verified, so callers respond generically. When the account exists and is + * unverified, a FRESH token is armed (replacing any pending one, so an old + * link stops working) and its plaintext returned for the caller to email. + */ + public function resendVerification(string $email): ?string + { + if ($email === '') { + return null; + } + + $user = $this->repository->findByIdentifier($email); + + if ($user === null) { + $this->audit->record('user.email_verify.resend_miss'); + return null; + } + if ($user->isEmailVerified()) { + // Nothing to send — an already-active account must not be re-armed. + $this->audit->record('user.email_verify.resend_noop', userId: $user->id()); + return null; + } + + // Same mechanism as signup: emailed once, only the SHA-256 stored, + // time-boxed + one-time. Re-arming invalidates the previous token. + $plainToken = bin2hex(random_bytes(32)); + $expiresAt = (new \DateTimeImmutable())->modify('+' . self::VERIFICATION_TTL . ' seconds'); + + $this->transaction->begin(); + try { + $user->startEmailVerification(hash('sha256', $plainToken), $expiresAt); + $user->commitChanges(); + $this->repository->update($user); + $this->transaction->commit(); + } catch (\Throwable $e) { + $this->transaction->rollback(); + throw $this->wrap($e, 'user.verify_email.resend_failed', ['id' => $user->id()]); + } + + $this->audit->record('user.email_verify.resent', userId: $user->id()); + + return $plainToken; } /** @@ -176,22 +239,26 @@ private function provision(RegisterUserDTO $dto): array // Emailed once; only its hash is stored. Time-boxed + one-time. $plainToken = bin2hex(random_bytes(32)); - $expiresAt = (new \DateTimeImmutable())->modify('+' . self::VERIFICATION_TTL . ' seconds'); + $expiresAt = (new \DateTimeImmutable())->modify('+' . self::VERIFICATION_TTL . ' seconds'); $this->collector->beginCollection(); $this->transaction->begin(); try { $user = User::register( - username: $dto->username, - email: $dto->email, + username: $dto->username, + email: $dto->email, passwordHash: $this->hasher->make($dto->password), ); $user->startEmailVerification(hash('sha256', $plainToken), $expiresAt); + // Persist the identity row FIRST, so the outbox event (and its + // userId) is only written once the user actually exists — both land + // in the same central transaction and commit atomically. + $this->repository->insert($user); + // Profile (if submitted) rides on the event for a tenant-side write; // it CANNOT join this central identity transaction (different DB). - $this->flushEvents($user, $dto->tenantId, $dto->profile); - $this->repository->insert($user); + $pending = $this->flushEvents($user, $dto->tenantId, $dto->profile); $this->transaction->commit(); } catch (\Throwable $e) { $this->transaction->rollback(); @@ -200,16 +267,29 @@ private function provision(RegisterUserDTO $dto): array } $this->collector->release(); - $this->audit->record('user.registered', ['userId' => $user->id()]); + $this->deliver($pending); + $this->audit->record('user.registered', userId: $user->id()); return [UserDTO::fromEntity($user), $plainToken]; } - public function find(string $id): ?UserDTO + public function find(string $id, bool $checkMembership = false): ?UserDTO { $this->requireSelfOrPermission($id, 'user:read-any'); $user = $this->repository->find($id); + if ($checkMembership) { + $membership = $this->membership !== null && $this->tenantId !== null && $user !== null + ? $this->membership->activeMember($user->id(), $this->tenantId) + : null; + + if (is_null($membership) && $this->tenantId !== null) { + $this->audit->record('user.login.no_membership', meta: ['id' => self::pseudonymise($id), 'tenantId' => $this->tenantId]); + return null; + } + + $user?->setMembership($membership); + } return $user === null ? null : UserDTO::fromEntity($user); } @@ -226,7 +306,7 @@ public function update(string $id, UpdateUserDTO $dto): ?UserDTO } $newUsername = $dto->username?->value() ?? $user->username(); - $newEmail = $dto->email?->value() ?? $user->email(); + $newEmail = $dto->email?->value() ?? $user->email(); if ($this->repository->existsByUsernameOrEmail($newUsername, $newEmail, exceptUserId: $id)) { throw new ValidationException(['username' => 'Username or email is already taken.']); } @@ -254,7 +334,7 @@ public function update(string $id, UpdateUserDTO $dto): ?UserDTO return UserDTO::fromEntity($user); } - $this->flushEvents($user); + $pending = $this->flushEvents($user); $this->repository->update($user); $this->transaction->commit(); } catch (\Throwable $e) { @@ -264,7 +344,8 @@ public function update(string $id, UpdateUserDTO $dto): ?UserDTO } $this->collector->release(); - $this->audit->record('user.updated', ['userId' => $id]); + $this->deliver($pending); + $this->audit->record('user.updated', userId: $id); return UserDTO::fromEntity($user); } @@ -290,7 +371,7 @@ public function verifyEmail(string $id, VerifyEmailDTO $dto): ?UserDTO return UserDTO::fromEntity($user); // already verified — idempotent } - $this->flushEvents($user); + $pending = $this->flushEvents($user); $this->repository->update($user); $this->transaction->commit(); } catch (\Throwable $e) { @@ -300,47 +381,67 @@ public function verifyEmail(string $id, VerifyEmailDTO $dto): ?UserDTO } $this->collector->release(); - $this->audit->record('user.email_verified', ['userId' => $id]); + $this->deliver($pending); + $this->audit->record('user.email_verified', userId: $id); return UserDTO::fromEntity($user); } public function verifyCredentials(string $identifier, string $password): ?UserDTO { - // 1. Lockout gate — refuse before any DB/hash work. - if ($this->isLockedOut($identifier)) { - $this->audit->record('user.login.locked_out', ['id' => self::pseudonymise($identifier)]); - return null; - } - $user = $this->repository->findByIdentifier($identifier); + try { + // 1. Lockout gate — refuse before any DB/hash work. + if ($this->isLockedOut($identifier)) { + $this->audit->record('user.login.locked_out', meta: ['id' => self::pseudonymise($identifier)]); + return null; + } + $user = $this->repository->findByIdentifier($identifier); + $membership = $this->membership !== null && $this->tenantId !== null && $user !== null + ? $this->membership->activeMember($user->id(), $this->tenantId) + : null; + + if (is_null($membership) && $this->tenantId !== null) { + $this->audit->record('user.login.no_membership', meta: ['id' => self::pseudonymise($identifier), 'tenantId' => $this->tenantId]); + return null; + } - // 2. Timing-safe: run a hash comparison even when the user is unknown. - $hash = $user?->passwordHash() ?? self::DECOY_HASH; - $ok = $this->hasher->check($password, $hash); + $user?->setMembership($membership); + + // 2. Timing-safe: run a hash comparison even when the user is unknown. + $hash = $user?->passwordHash() ?? self::DECOY_HASH; + $ok = $this->hasher->check($password, $hash); - if (!$ok || $user === null || !$user->canLogin()) { - $this->recordLoginFailure($identifier); - $this->audit->record('user.login.failed', ['id' => self::pseudonymise($identifier)]); - return null; - } - // 3. Success — clear failures and transparently upgrade the hash if the - // cost factor changed since it was created. - $this->clearLoginFailures($identifier); - if ($this->hasher->needsRehash($hash)) { - try { - $this->repository->persistRehash($user->id(), $this->hasher->make($password)); - $this->audit->record('user.password.rehashed', ['userId' => $user->id()]); - } catch (\Throwable) { - // A rehash failure must never block a valid login. + if (!$ok || $user === null || !$user->canLogin()) { + $this->recordLoginFailure($identifier); + $this->audit->record('user.login.failed', meta: ['id' => self::pseudonymise($identifier)]); + + + return null; + } + // 3. Success — clear failures and transparently upgrade the hash if the + // cost factor changed since it was created. + $this->clearLoginFailures($identifier); + if ($this->hasher->needsRehash($hash)) { + try { + $this->repository->persistRehash($user->id(), $this->hasher->make($password)); + $this->audit->record('user.password.rehashed', userId: $user->id()); + } catch (\Throwable) { + // A rehash failure must never block a valid login. + } } + + + return UserDTO::fromEntity($user); + + } catch (\Throwable $e) { + throw $this->wrap($e, 'user.verify_credentials.failed'); } - return UserDTO::fromEntity($user); } - public function findByIdentifier(string $identifier): ?UserDTO + public function findByIdentifier(string $identifier, bool $checkMembership = false): ?UserDTO { if ($identifier === '') { return null; @@ -348,6 +449,19 @@ public function findByIdentifier(string $identifier): ?UserDTO $user = $this->repository->findByIdentifier($identifier); + if ($checkMembership) { + $membership = $this->membership !== null && $this->tenantId !== null && $user !== null + ? $this->membership->activeMember($user->id(), $this->tenantId) + : null; + + if (is_null($membership) && $this->tenantId !== null) { + $this->audit->record('user.login.no_membership', meta: ['id' => self::pseudonymise($identifier), 'tenantId' => $this->tenantId]); + return null; + } + + $user?->setMembership($membership); + } + return $user === null ? null : UserDTO::fromEntity($user); } @@ -373,7 +487,7 @@ public function resetPassword(string $userId, string $newPassword): bool throw $this->wrap($e, 'user.password.reset_failed', ['id' => $userId]); } - $this->audit->record('user.password.reset', ['userId' => $userId]); + $this->audit->record('user.password.reset', userId: $userId); return true; } @@ -385,7 +499,16 @@ public function findByRememberToken(string $token): ?UserDTO } $user = $this->repository->findByRememberToken(hash('sha256', $token)); + $membership = $this->membership !== null && $this->tenantId !== null && $user !== null + ? $this->membership->activeMember($user->id(), $this->tenantId) + : null; + if (is_null($membership) && $this->tenantId !== null) { + $this->audit->record('user.find_by_token.no_membership', meta: ['id' => self::pseudonymise($token), 'tenantId' => $this->tenantId]); + return null; + } + + $user?->setMembership($membership); if ($user === null || !$user->canLogin()) { return null; } @@ -393,7 +516,7 @@ public function findByRememberToken(string $token): ?UserDTO return UserDTO::fromEntity($user); } - public function cycleRememberToken(string $userId): string + public function cycleRememberToken(string $userId, bool $checkMembership = false): string { $plaintext = bin2hex(random_bytes(32)); $this->repository->updateRememberToken($userId, hash('sha256', $plaintext)); @@ -401,19 +524,33 @@ public function cycleRememberToken(string $userId): string return $plaintext; } - public function clearRememberToken(string $userId): void + public function clearRememberToken(string $userId, bool $checkMembership = false): void { $this->repository->updateRememberToken($userId, null); } - public function delete(string $id): bool + public function delete(string $id, bool $checkMembership = false): bool { $this->requireSelfOrPermission($id, 'user:delete-any'); $user = $this->repository->find($id); + if ($user === null) { return false; } + if ($checkMembership) { + + $membership = $this->membership !== null && $this->tenantId !== null && $user !== null + ? $this->membership->activeMember($user->id(), $this->tenantId) + : null; + + if (is_null($membership) && $this->tenantId !== null) { + $this->audit->record('user.delete.no_membership', meta: ['id' => self::pseudonymise($id), 'tenantId' => $this->tenantId]); + return false; + } + + $user?->setMembership($membership); + } $this->collector->beginCollection(); $this->transaction->begin(); @@ -426,7 +563,7 @@ public function delete(string $id): bool } $user->markDeleted(); - $this->flushEvents($user); + $pending = $this->flushEvents($user); $this->transaction->commit(); } catch (\Throwable $e) { $this->transaction->rollback(); @@ -435,7 +572,10 @@ public function delete(string $id): bool } $this->collector->release(); - $this->audit->record('user.deleted', ['userId' => $id]); + if (count($pending) > 0) { + $this->deliver($pending); + } + $this->audit->record('user.deleted', userId: $id); return true; } @@ -446,36 +586,67 @@ public function delete(string $id): bool * Collect the entity's domain events and write their integration * counterparts to the outbox — all inside the active transaction. */ - private function flushEvents(User $user, string $originTenant = '', array $profile = []): void + /** + * Collect the entity's domain events and write their integration + * counterparts to the outbox — all inside the active transaction. Returns + * the written rows keyed by outbox id so the caller can dispatch them + * in-process after commit (see deliver()). + * + * @return array + */ + private function flushEvents(User $user, string $originTenant = '', array $profile = []): array { + $pending = []; + foreach ($user->releaseEvents() as $event) { $this->collector->collect($event); $integration = $this->toIntegration($event, $originTenant, $profile); if ($integration !== null) { + /** incase you want all event to fire there and then */ + // $pending[$this->outbox->write($integration)] = $integration; $this->outbox->write($integration); } } + + return $pending; + } + + /** + * Dispatch the just-committed integration events in-process and mark their + * outbox rows dispatched. The EventBus isolates listener failures, so a bad + * subscriber never blocks the mark. The relay therefore only re-delivers + * rows a crash between commit and dispatch left pending — at-least-once with + * no double-fire on the happy path. + * + * @param array $pending + */ + private function deliver(array $pending): void + { + foreach ($pending as $id => $event) { + $this->eventBus->dispatch($event); + $this->outbox->markDispatched($id); + } } private function toIntegration(DomainEventContract $event, string $originTenant = '', array $profile = []): ?IntegrationEventContract { return match (true) { $event instanceof UserRegisteredDomainEvent => new UserRegisteredIntegrationEvent( - userId: $event->userId->value(), - username: $event->username->value(), - email: $event->email->value(), + userId: $event->userId->value(), + username: $event->username->value(), + email: $event->email->value(), occurredAt: $event->occurredAt->format(\DateTimeInterface::RFC3339), - tenantId: $originTenant, - profile: $profile, + tenantId: $originTenant, + profile: $profile, ), $event instanceof UserUpdatedDomainEvent => new UserUpdatedIntegrationEvent( - userId: $event->userId->value(), - changed: $event->changed, + userId: $event->userId->value(), + changed: $event->changed, occurredAt: $event->occurredAt->format(\DateTimeInterface::RFC3339), ), $event instanceof UserDeletedDomainEvent => new UserDeletedIntegrationEvent( - userId: $event->userId->value(), + userId: $event->userId->value(), occurredAt: $event->occurredAt->format(\DateTimeInterface::RFC3339), ), default => null, @@ -486,7 +657,8 @@ private function wrap(\Throwable $e, string $code, array $context = []): \Throwa { // Preserve typed domain/security/validation faults so the kernel maps // them to the right HTTP status (409/422/403) instead of a blanket 500. - if ($e instanceof ServiceException + if ( + $e instanceof ServiceException || $e instanceof ValidationException || $e instanceof SecurityException || $e instanceof \AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\DomainException diff --git a/plugins/User/Application/Services/UserSettingsService.php b/plugins/User/Application/Services/UserSettingsService.php index 46c6738..b62bceb 100644 --- a/plugins/User/Application/Services/UserSettingsService.php +++ b/plugins/User/Application/Services/UserSettingsService.php @@ -15,7 +15,7 @@ use Plugins\User\Domain\Entities\UserPreferences; use Plugins\User\Domain\Entities\UserPrivacySettings; use Plugins\User\Domain\Entities\UserProfile; -use Plugins\User\Infrastructure\Audit\AuditLogger; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\User\Infrastructure\Persistence\UserSettingsRepository; /** @@ -33,7 +33,7 @@ final class UserSettingsService public function __construct( private readonly UserSettingsRepository $repository, private readonly Identity $identity, - private readonly AuditLogger $audit, + private readonly AuditServiceContract $audit, ) {} // ── profile ─────────────────────────────────────────────────────────────── @@ -64,7 +64,7 @@ public function updateProfile(UpdateProfileDTO $dto): UserProfile } $this->repository->saveProfile($profile); - $this->audit->record('user.profile.updated', ['userId' => $userId]); + $this->audit->record('user.profile.updated', userId: $userId); return $profile; } @@ -98,7 +98,7 @@ public function updatePreferences(UpdatePreferencesDTO $dto): UserPreferences } $this->repository->savePreferences($prefs); - $this->audit->record('user.preferences.updated', ['userId' => $userId]); + $this->audit->record('user.preferences.updated', userId: $userId); return $prefs; } @@ -127,8 +127,7 @@ public function updatePrivacy(UpdatePrivacyDTO $dto): UserPrivacySettings $this->repository->savePrivacy($settings); // Privacy/marketing toggles are compliance-relevant — record the change. - $this->audit->record('user.privacy.updated', [ - 'userId' => $userId, + $this->audit->record('user.privacy.updated', userId: $userId, meta: [ 'marketingOptIn' => $dto->marketingOptIn, 'analyticsOptIn' => $dto->analyticsOptIn, ]); @@ -160,7 +159,7 @@ public function updateNotifications(UpdateNotificationPreferencesDTO $dto): User } $this->repository->saveNotifications($prefs); - $this->audit->record('user.notification_preferences.updated', ['userId' => $userId]); + $this->audit->record('user.notification_preferences.updated', userId: $userId); return $prefs; } diff --git a/plugins/User/Domain/Entities/User.php b/plugins/User/Domain/Entities/User.php index 83cac1e..dcb8ba7 100644 --- a/plugins/User/Domain/Entities/User.php +++ b/plugins/User/Domain/Entities/User.php @@ -4,6 +4,8 @@ namespace Plugins\User\Domain\Entities; +use Plugins\Tenancy\API\DTOs\TenantSummary; +use Plugins\User\API\IntegrationEvents\UserRegisteredIntegrationEvent; use Plugins\User\Domain\Events\UserDeletedDomainEvent; use Plugins\User\Domain\Events\UserRegisteredDomainEvent; use Plugins\User\Domain\Events\UserUpdatedDomainEvent; @@ -35,12 +37,34 @@ final class User extends Entity 'email_verified_at' => 'datetime', 'email_verification_expires_at' => 'datetime', 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; /** Credentials + the verification token hash never cross the serialization boundary. */ protected array $hidden = ['password_hash', 'remember_token', 'email_verification_token_hash']; + protected TenantSummary|null $membership = null; + + + /** + * Summary of setMembership + * @param mixed $membership + * @return void + */ + public function setMembership(?TenantSummary $membership): void + { + $this->membership = $membership; + } + + /** + * Summary of getMembership + * @return TenantSummary|null + */ + public function getMembership(): ?TenantSummary + { + return $this->membership; + } /** * Register a brand-new user. $passwordHash MUST already be a bcrypt hash @@ -76,6 +100,7 @@ public static function register( email: $email, occurredAt: $createdAt, )); + return $user; } @@ -118,9 +143,13 @@ public function verifyEmail(): void return; } $this->email_verified_at = new \DateTimeImmutable(); - // A consumed/confirmed account holds no live token. - $this->email_verification_token_hash = null; - $this->email_verification_expires_at = null; + // The token hash + expiry are deliberately KEPT (not nulled): once the + // account is verified, email_verified_at is the authoritative gate, so a + // second click of the SAME (still-unexpired) link resolves to the same + // user and is reported as "already verified" instead of a confusing + // "invalid link". It confers no new power — verifyEmail() short-circuits + // above, so the token can never re-verify or mutate state — and it self- + // expires at its original TTL. Do NOT re-null these here. } /** diff --git a/plugins/User/Infrastructure/Audit/AuditLogger.php b/plugins/User/Infrastructure/Audit/AuditLogger.php deleted file mode 100644 index 971ddd4..0000000 --- a/plugins/User/Infrastructure/Audit/AuditLogger.php +++ /dev/null @@ -1,106 +0,0 @@ -sink = $sink ?? static fn(string $line) => error_log($line); - } - - /** @param array $context */ - public function record(string $action, array $context = []): void - { - $occurredAt = (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339); - - $entry = json_encode([ - 'source' => 'user_audit', - 'action' => $action, - 'actor' => $this->actorId, - 'context' => $context, - 'timestamp' => $occurredAt, - ], JSON_UNESCAPED_SLASHES); - - if ($entry !== false) { - ($this->sink)($entry); - } - - $this->persist($action, $context, $occurredAt); - } - - /** - * Persist to the shared `audit_log` table. Best-effort: any failure is - * swallowed (already captured in the log line) so auditing never aborts the - * audited action. `userId` in context maps to the user_id column; everything - * else is kept in the JSON `meta` column. - * - * @param array $context - */ - private function persist(string $action, array $context, string $occurredAt): void - { - if ($this->db === null) { - return; - } - - $userId = isset($context['userId']) ? (string) $context['userId'] : ($this->actorId ?: null); - $ip = isset($context['ip']) ? (string) $context['ip'] : null; - - $meta = $context; - unset($meta['userId'], $meta['ip']); - $metaJson = $meta === [] ? null : json_encode($meta, JSON_UNESCAPED_SLASHES); - - try { - $this->db->execute( - 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) - VALUES (:event_id, :user_id, :tenant_id, :action, :ip, :meta, :occurred_at)', - [ - 'event_id' => Ulid::generate(), - 'user_id' => $userId, - 'tenant_id' => ($this->tenantId ?? '') !== '' ? $this->tenantId : null, - 'action' => $action, - 'ip' => $ip, - 'meta' => $metaJson === false ? null : $metaJson, - 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\Throwable) { - // Best-effort — the log line above is the durable fallback. - } - } -} diff --git a/plugins/User/Infrastructure/Cli/RelayUserOutboxCommand.php b/plugins/User/Infrastructure/Cli/RelayUserOutboxCommand.php index e69cb70..680a312 100644 --- a/plugins/User/Infrastructure/Cli/RelayUserOutboxCommand.php +++ b/plugins/User/Infrastructure/Cli/RelayUserOutboxCommand.php @@ -5,7 +5,7 @@ namespace Plugins\User\Infrastructure\Cli; use AlfacodeTeam\PhpIoCli\AbstractCommand; -use Plugins\User\Infrastructure\Outbox\OutboxRelay; +use Plugins\User\Application\Services\OutboxRelayService; /** * user:outbox:relay — dispatch pending user integration events to the EventBus. @@ -17,7 +17,7 @@ final class RelayUserOutboxCommand extends AbstractCommand { public function __construct( - private readonly OutboxRelay $relay, + private readonly OutboxRelayService $relay, ) { parent::__construct(); } diff --git a/plugins/User/Infrastructure/Http/Controllers/UserController.php b/plugins/User/Infrastructure/Http/Controllers/UserController.php index c571e1b..ec03452 100644 --- a/plugins/User/Infrastructure/Http/Controllers/UserController.php +++ b/plugins/User/Infrastructure/Http/Controllers/UserController.php @@ -5,6 +5,7 @@ namespace Plugins\User\Infrastructure\Http\Controllers; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort; use Plugins\User\API\Contracts\UserServiceContract; use Plugins\User\API\DTOs\ListUsersQuery; @@ -51,7 +52,7 @@ public function register(): Response $this->queueVerificationEmail($dto->email->value(), $token); return Response::json(['status' => 'pending_verification'], 202) - ->withHeader('Location', '/account/verify'); + ->withHeader('Location', '/verify-email'); } /** @@ -93,13 +94,138 @@ public function adminCreate(): Response * Token in the request body/query; no identity required. Always a generic * response so a bad/expired token reveals nothing. */ + /** Cookie that binds a resend to the email of a just-attempted expired token. */ + private const RESEND_BIND_COOKIE = 'vrf_bind'; + private const RESEND_BIND_MINUTES = 30; + + /** Per-email resend cap: max sends within the window (defence-in-depth). */ + private const RESEND_MAX_PER_EMAIL = 3; + private const RESEND_EMAIL_WINDOW = 3600; + public function verifyEmailByToken(): Response { - $token = (string) $this->resolveRequest()->input('token', ''); + $token = (string) $this->resolveRequest()->input('token', ''); + + $result = $this->users->verifyEmailByToken($token); + + return match ($result->status) { + UserServiceContract::VERIFY_OK => Response::json(['status' => 'verified']), + // Disclosed ONLY because a valid token proves inbox control. + UserServiceContract::VERIFY_ALREADY => Response::json([ + 'status' => 'already_verified', + 'message' => 'Your email is already verified — you can sign in.', + ]), + // Correct token, past its TTL. Distinct code (`token_expired`) so the + // client can surface the resend option directly. Safe to disclose: + // only a real, matched token reaches this branch. We ALSO bind this + // browser to the matched email (keyed HMAC, encrypted HttpOnly + // cookie) so the follow-up resend can only target THIS address. + UserServiceContract::VERIFY_EXPIRED => $this->expiredResponse($result->email), + default => $this->unprocessable( + ['token' => 'This verification link is invalid or has expired.'], + ), + }; + } + + /** 422 for an expired-but-matched token + the resend-binding cookie. */ + private function expiredResponse(?string $email): Response + { + if ($email !== null && $email !== '') { + $this->queueCookie(self::RESEND_BIND_COOKIE, $this->bindHash($email), self::RESEND_BIND_MINUTES); + } + + return Response::json([ + 'error' => [ + 'code' => 'token_expired', + 'message' => 'This verification link has expired. Request a new one below.', + 'fields' => ['token' => 'This verification link has expired.'], + ], + ], 422); + } + + /** + * PUBLIC resend of the verification email — the recovery path when a link is + * expired/invalid. Enumeration-safe: the service returns a token only for an + * unverified account, and this endpoint ALWAYS answers with the same generic + * 202 (never revealing whether the email is registered or already verified). + * + * EXTRA LAYER: if this browser recently presented an expired token (the + * `vrf_bind` cookie is set), the submitted email MUST match the one bound to + * that attempt — otherwise the request is blocked. This stops a browser that + * proved control of address A from firing resends at an arbitrary address B. + */ + public function resendVerification(): Response + { + $email = trim((string) $this->resolveRequest()->input('email', '')); + + $bound = $this->cookie(self::RESEND_BIND_COOKIE); + + if ($bound !== null && $bound !== '' + && !hash_equals($bound, $this->bindHash($email))) { + // Bound to a different address than the one submitted — refuse, and + // give nothing away about either address. + return Response::forbidden('This email does not match your pending verification request.'); + } + + // Per-EMAIL send cap (defence-in-depth over the per-IP route throttle): + // a victim's inbox can't be flooded even from many IPs. Over quota, we + // silently skip the send but still return the same generic 202 — no + // observable difference, so enumeration-safety holds. + if ($email !== '' && $this->withinResendQuota($email)) { + + $token = $this->users->resendVerification($email); + if ($token !== null) { + $this->queueVerificationEmail($email, $token); + } + } - return $this->users->verifyEmailByToken($token) - ? Response::json(['status' => 'verified']) - : $this->unprocessable(['token' => 'This verification link is invalid or has expired.']); + // One-shot binding: clear it so the cookie can't be replayed. + $this->forgetCookie(self::RESEND_BIND_COOKIE); + + return Response::json([ + 'status' => 'pending_verification', + 'message' => 'If that address needs verifying, we\'ve sent a new link. Check your inbox.', + 'token' => $token, // never echo the token back to the client + ], 202); + } + + /** + * True while the submitted email is under its resend quota (default 3 per + * hour), incrementing the counter as a side effect. Fails OPEN when no cache + * is available — the per-IP route throttle still applies. + */ + private function withinResendQuota(string $email): bool + { + $container = $this->resolveRequest()->container(); + if ($container === null || !$container->has(CachePort::class)) { + return true; + } + $cache = $container->make(CachePort::class); + if (!$cache instanceof CachePort) { + return true; + } + + $key = 'vrf_send_' . hash('sha256', strtolower($email)); + $count = (int) ($cache->get($key) ?? 0); + if ($count >= self::RESEND_MAX_PER_EMAIL) { + return false; + } + + $count === 0 + ? $cache->set($key, 1, self::RESEND_EMAIL_WINDOW) + : $cache->increment($key); + + return true; + } + + /** + * Keyed HMAC of a normalised email. Stored (encrypted) in the binding cookie + * so the raw address is never written to the client, and compared in + * constant time on resend. + */ + private function bindHash(string $email): string + { + return hash_hmac('sha256', strtolower(trim($email)), (string) env('APP_KEY')); } public function show(string $id): Response diff --git a/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php b/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php index 839d9b3..832df35 100644 --- a/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php +++ b/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php @@ -57,6 +57,18 @@ public function register(Request $request): Response return $this->pageflow->render($request, 'User/Register', 'admin'); } + /** + * Public: email-verification landing → component "User/VerifyEmail". The + * emailed link points here (`/verify-email?token=...`); the token is passed + * as a prop so the page can prefill and POST it to /ajx/users/verify. + */ + public function verifyEmail(Request $request): Response + { + return $this->pageflow->render($request, 'User/VerifyEmail', 'admin', [ + 'token' => (string) $request->query('token', ''), + ]); + } + /** Public: the signed-in user's own profile → component "User/Profile". */ public function profile(Request $request): Response { diff --git a/plugins/User/Infrastructure/Http/Controllers/UserPageController.php b/plugins/User/Infrastructure/Http/Controllers/UserPageController.php index fb43d98..c42d8ab 100644 --- a/plugins/User/Infrastructure/Http/Controllers/UserPageController.php +++ b/plugins/User/Infrastructure/Http/Controllers/UserPageController.php @@ -43,6 +43,19 @@ public function edit(string $id): Response return $this->page('user::users/edit', ['title' => 'Edit user', 'userId' => $id]); } + /** + * Email-verification landing page. The link emailed on public signup points + * here (`GET /verify-email?token=...`); the page prefills the token from the + * query string (if present) and POSTs it to `/ajx/users/verify`. Also usable + * as a manual "paste your token" form when the link was not followed. + */ + public function verify(): Response + { + $token = (string) $this->resolveRequest()->query('token', ''); + + return $this->page('user::account/verify', ['title' => 'Verify email', 'token' => $token]); + } + /** Account settings demo — read/update CRUD for the 4 settings resources. */ public function settings(): Response { diff --git a/plugins/User/Infrastructure/Outbox/OutboxRelay.php b/plugins/User/Infrastructure/Outbox/OutboxRelay.php deleted file mode 100644 index 032d055..0000000 --- a/plugins/User/Infrastructure/Outbox/OutboxRelay.php +++ /dev/null @@ -1,90 +0,0 @@ -pending($limit); - $dispatched = 0; - - foreach ($rows as $row) { - try { - $payload = json_decode((string) $row['payload'], true, 512, JSON_THROW_ON_ERROR); - - $this->eventBus->dispatch(new GenericIntegrationEvent( - name: (string) $row['event_name'], - version: (string) $row['event_version'], - payload: is_array($payload) ? $payload : [], - )); - - $this->markDispatched((int) $row['id']); - $dispatched++; - } catch (\Throwable $e) { - $this->markFailed((int) $row['id'], (int) $row['attempts'] + 1, $e->getMessage()); - } - } - - return $dispatched; - } - - /** @return list> */ - private function pending(int $limit): array - { - try { - return $this->db->query( - 'SELECT id, event_name, event_version, payload, attempts - FROM user_outbox - WHERE status = 0 - ORDER BY occurred_at ASC, id ASC - LIMIT :limit', - ['limit' => max(1, $limit)], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to read outbox.', layer: 'repository.user.outbox', previous: $e); - } - } - - private function markDispatched(int $id): void - { - $this->db->execute( - 'UPDATE user_outbox SET status = 1, dispatched_at = :now, attempts = attempts + 1 - WHERE id = :id AND status = 0', - ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'id' => $id], - ); - } - - private function markFailed(int $id, int $attempts, string $error): void - { - $status = $attempts >= self::MAX_ATTEMPTS ? 2 : 0; // park as failed, else retry next run - $this->db->execute( - 'UPDATE user_outbox SET status = :status, attempts = :attempts, last_error = :err - WHERE id = :id', - ['status' => $status, 'attempts' => $attempts, 'err' => mb_substr($error, 0, 1000), 'id' => $id], - ); - } -} diff --git a/plugins/User/Infrastructure/Outbox/OutboxWriter.php b/plugins/User/Infrastructure/Outbox/OutboxWriter.php deleted file mode 100644 index 9c7ab31..0000000 --- a/plugins/User/Infrastructure/Outbox/OutboxWriter.php +++ /dev/null @@ -1,72 +0,0 @@ -db->execute( - 'INSERT INTO user_outbox - (event_id, event_name, event_version, payload, - status, attempts, occurred_at, created_at) - VALUES - (:event_id, :event_name, :event_version, :payload, - 0, 0, :occurred_at, :created_at)', - [ - 'event_id' => self::uuid(), - 'event_name' => $event->name(), - 'event_version' => $event->version(), - 'payload' => json_encode($event->payload(), JSON_THROW_ON_ERROR), - 'occurred_at' => self::now(), - 'created_at' => self::now(), - ], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to enqueue outbox event.', - layer: 'repository.user.outbox', - context: ['event' => $event->name()], - previous: $e, - ); - } - } - - private static function now(): string - { - return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - } - - private static function uuid(): string - { - $b = random_bytes(16); - $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); - $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); - } -} diff --git a/plugins/User/Infrastructure/Persistence/OutboxRepository.php b/plugins/User/Infrastructure/Persistence/OutboxRepository.php new file mode 100644 index 0000000..64048f7 --- /dev/null +++ b/plugins/User/Infrastructure/Persistence/OutboxRepository.php @@ -0,0 +1,121 @@ +db->execute( + 'INSERT INTO user_outbox + (event_id, event_name, event_version, payload, + status, attempts, occurred_at, created_at) + VALUES + (:event_id, :event_name, :event_version, :payload, + 0, 0, :occurred_at, :created_at)', + [ + 'event_id' => self::uuid(), + 'event_name' => $event->name(), + 'event_version' => $event->version(), + 'payload' => json_encode($event->payload(), JSON_THROW_ON_ERROR), + 'occurred_at' => self::now(), + 'created_at' => self::now(), + ], + ); + + return (int) $this->db->lastInsertId(); + } catch (\Throwable $e) { + throw new RepositoryException( + 'Failed to enqueue outbox event.', + layer: 'repository.user.outbox', + context: ['event' => $event->name()], + previous: $e, + ); + } + } + + // ── relay side (read + status transitions) ─────────────────────────────── + + /** @return list> Pending rows, oldest first. */ + public function pending(int $limit): array + { + // LIMIT must be inlined as a validated integer: bound params are sent as + // strings (execute($params) → PDO::PARAM_STR), and native prepares + // (EMULATE_PREPARES=false) reject `LIMIT '100'` as a syntax error. + $limit = max(1, min(1000, $limit)); + + try { + return $this->db->query( + 'SELECT id, event_name, event_version, payload, attempts + FROM user_outbox + WHERE status = 0 + ORDER BY occurred_at ASC, id ASC + LIMIT ' . $limit, + ); + } catch (\Throwable $e) { + throw new RepositoryException('Failed to read outbox.', layer: 'repository.user.outbox', previous: $e); + } + } + + public function markDispatched(int $id): void + { + $this->db->execute( + 'UPDATE user_outbox SET status = 1, dispatched_at = :now, attempts = attempts + 1 + WHERE id = :id AND status = 0', + ['now' => self::now(), 'id' => $id], + ); + } + + public function markFailed(int $id, int $attempts, string $error): void + { + $status = $attempts >= self::MAX_ATTEMPTS ? 2 : 0; // park as failed, else retry next run + $this->db->execute( + 'UPDATE user_outbox SET status = :status, attempts = :attempts, last_error = :err + WHERE id = :id', + ['status' => $status, 'attempts' => $attempts, 'err' => mb_substr($error, 0, 1000), 'id' => $id], + ); + } + + private static function now(): string + { + return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + } + + private static function uuid(): string + { + $b = random_bytes(16); + $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); + $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); + } +} diff --git a/plugins/User/Infrastructure/Persistence/UserRepository.php b/plugins/User/Infrastructure/Persistence/UserRepository.php index 94b3970..0e63434 100644 --- a/plugins/User/Infrastructure/Persistence/UserRepository.php +++ b/plugins/User/Infrastructure/Persistence/UserRepository.php @@ -50,7 +50,10 @@ public function __construct( */ public function paginate(ListUsersQuery $query): array { - $params = ['limit' => $query->limit + 1]; + // Inline LIMIT as a validated int: bound params bind as strings and + // native prepares (EMULATE_PREPARES=false) reject `LIMIT '100'`. + $limit = max(1, min(1001, $query->limit + 1)); + $params = []; $cursor = ''; if ($query->after !== null) { // user_id DESC → fetch rows strictly "older" than the cursor. @@ -63,7 +66,7 @@ public function paginate(ListUsersQuery $query): array 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . ' WHERE deleted_at IS NULL' . $cursor . ' ORDER BY user_id DESC - LIMIT :limit', + LIMIT ' . $limit, $params, ); } catch (\Throwable $e) { @@ -168,9 +171,12 @@ public function existsByUsernameOrEmail(string $username, string $email, ?string return $row !== null; } - public function insert(User $user): void + public function insert(User &$user): void { - $now = self::now(); + // The entity is the source of truth for created_at (set at register()). + // updated_at equals it on first insert. + $createdAt = $user->createdAt(); + $updatedAt = $createdAt; try { $this->db->execute( @@ -192,8 +198,8 @@ public function insert(User $user): void 'email_verified_at' => self::fmt($user->emailVerifiedAt()), 'verif_token' => $user->emailVerificationTokenHash(), 'verif_expires' => self::fmt($user->emailVerificationExpiresAt()), - 'created_at' => $now, - 'updated_at' => $now, + 'created_at' => self::fmt($createdAt), + 'updated_at' => self::fmt($updatedAt), ], ); } catch (\Throwable $e) { @@ -207,6 +213,11 @@ public function insert(User $user): void previous: $e, ); } + + // Reflect the persisted timestamps back onto the caller's entity, then + // mark it clean so it reports no pending changes after the write. + $user->setAttribute('updated_at', $updatedAt); + $user->syncOriginal(); } /** @@ -257,6 +268,7 @@ public function update(User $user): void previous: $e, ); } + if ($affected < 1) { throw new OptimisticLockException( diff --git a/plugins/User/Provider.php b/plugins/User/Provider.php index 84e1548..8d6361f 100644 --- a/plugins/User/Provider.php +++ b/plugins/User/Provider.php @@ -16,21 +16,24 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HashingPort; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HttpClientPort; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort; +use Plugins\Tenancy\API\Contracts\MembershipServiceContract; use Plugins\User\Application\Ports\BreachChecker; use Plugins\User\Infrastructure\Gateways\NullBreachChecker; use Plugins\User\Infrastructure\Gateways\PwnedPasswordGateway; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\User\API\Contracts\UserServiceContract; +use Plugins\User\Application\Services\OutboxRelayService; use Plugins\User\Application\Services\UserService; use Plugins\User\Application\Services\UserSettingsService; -use Plugins\User\Infrastructure\Audit\AuditLogger; use Plugins\User\Infrastructure\Cli\RelayUserOutboxCommand; use Plugins\User\Infrastructure\Http\Controllers\UserController; use Plugins\User\Infrastructure\Http\Controllers\UserPageController; use Plugins\User\Infrastructure\Http\Controllers\UserSettingsController; use Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener; -use Plugins\User\Infrastructure\Outbox\OutboxWriter; +use Plugins\User\Application\Ports\OutboxPort; +use Plugins\User\Infrastructure\Persistence\OutboxRepository; use Plugins\User\Infrastructure\Persistence\UserRepository; use Plugins\User\Infrastructure\Persistence\UserSettingsRepository; use Plugins\View\API\Contracts\ViewRendererContract; @@ -60,6 +63,7 @@ public function requires(): array { return [ DatabaseConnectionManagerContract::class, + AuditServiceContract::class, HashingPort::class, CachePort::class, ViewRendererContract::class, @@ -86,22 +90,11 @@ public function register(ModuleContainer $container): void $container->bindInternal(UserRepository::class, static fn(ModuleContainer $c) => new UserRepository(self::central($c))); - $container->bindInternal(OutboxWriter::class, static fn(ModuleContainer $c) => - new OutboxWriter(self::central($c))); - - $container->bindInternal(AuditLogger::class, static function (ModuleContainer $c) { - $identity = $c->make(Identity::class); - // Persist to the shared central `audit_log` table in addition to the - // log line (central connection — audit is never tenant-routed). The - // active tenant is published by Tenancy's TenantContextStage under the - // 'tenant.current' container key (a plain string — no Tenancy import). - $tenantId = $c->has('tenant.current') ? (string) $c->make('tenant.current') : null; - return new AuditLogger( - $identity->userId ?: null, - db: self::central($c), - tenantId: $tenantId, - ); - }); + // Sole data-access seam for user_outbox (write + relay ops); central conn. + $container->bindInternal(OutboxRepository::class, static fn(ModuleContainer $c) => + new OutboxRepository(self::central($c))); + $container->bind(OutboxPort::class, static fn(ModuleContainer $c) => + $c->make(OutboxRepository::class)); // Breached-password screening (NIST 800-63B). Enabled with // USER_BREACH_CHECK; uses the HIBP k-anonymity range API via @@ -120,16 +113,21 @@ public function register(ModuleContainer $container): void }); $container->bind(UserServiceContract::class, static fn(ModuleContainer $c) => - new UserService( + + + new UserService( repository: $c->make(UserRepository::class), transaction: $c->make(TransactionManager::class), collector: $c->make(DomainEventCollector::class), - outbox: $c->make(OutboxWriter::class), + outbox: $c->make(OutboxPort::class), + eventBus: $c->make(EventBus::class), hasher: $c->make(HashingPort::class), identity: $c->make(Identity::class), cache: $c->make(CachePort::class), - audit: $c->make(AuditLogger::class), + audit: $c->make(AuditServiceContract::class), breachChecker: $c->make(BreachChecker::class), + tenantId: $c->has('tenant.current') ? (string) $c->make('tenant.current') : null, + membership: $c->has(MembershipServiceContract::class) ? $c->make(MembershipServiceContract::class) : null, )); // Public/admin JSON controller. Bound explicitly so the OPTIONAL MailPort @@ -156,7 +154,7 @@ public function register(ModuleContainer $container): void new UserSettingsService( $c->make(UserSettingsRepository::class), $c->make(Identity::class), - $c->make(AuditLogger::class), + $c->make(AuditServiceContract::class), )); $container->bindInternal(UserSettingsController::class, static fn(ModuleContainer $c) => @@ -165,8 +163,26 @@ public function register(ModuleContainer $container): void public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void { - // Outbox relay — resolved via CoreContainer autowiring (DatabasePort + EventBus). - $cli->command(RelayUserOutboxCommand::class); + // Outbox relay — must read the CENTRAL `user_outbox` (the ConnectionManager + // default), NOT the kernel DatabasePort port (which a project may bind to + // an unrelated/unconfigured connection). Build it on the CLI path with a + // scoped container that carries the Database ConnectionManager so + // OutboxRepository targets the same central DB the write side uses. + // Deferred so HTTP/worker builds never pay for it. + $cli->defer(function (CliPipeline $cli) use ($events): void { + $c = new ModuleContainer($cli->container()); + $c->setScope('database.management'); + (new \Plugins\Database\Provider())->register($c); + $c->setScope('user.management'); + (new self())->register($c); + + $cli->command(new RelayUserOutboxCommand( + new OutboxRelayService( + $c->makeInScope(OutboxRepository::class, 'user.management'), + $events, + ), + )); + }); // Write the per-tenant user_profiles row from an at-signup profile block. // Resolved from the CoreContainer: the PROJECT binds this WITH a diff --git a/plugins/User/database/migrations/2026_01_01_000001_create_user_outbox_table.php b/plugins/User/database/migrations/2026_01_01_000001_create_user_outbox_table.php index 1f2d7b8..8675b9b 100644 --- a/plugins/User/database/migrations/2026_01_01_000001_create_user_outbox_table.php +++ b/plugins/User/database/migrations/2026_01_01_000001_create_user_outbox_table.php @@ -29,7 +29,7 @@ public function up(SchemaBuilderInterface $schema): void // WHY: The consumer-side idempotency key: at-least-once delivery // means a message can arrive twice, so consumers dedupe on // this value. CHAR(36) = canonical UUID string length. - // WHERE: Written by OutboxWriter; carried in the dispatched event so + // WHERE: Written by OutboxRepository; carried in the dispatched event so // downstream handlers can skip duplicates. uniq_event_id below. $t->char('event_id', 36)->comment('UUID — idempotency key for consumers'); @@ -37,7 +37,7 @@ public function up(SchemaBuilderInterface $schema): void // WHAT: The integration event's logical name (e.g. user.registered). // WHY: Lets the relay/consumers route by type without decoding the // payload. 100 chars comfortably fits any dotted event name. - // WHERE: Set from IntegrationEventContract::name() by OutboxWriter. + // WHERE: Set from IntegrationEventContract::name() by OutboxRepository. $t->string('event_name', 100)->comment('e.g. user.registered'); // --- Event version ---------------------------------------------- diff --git a/plugins/User/module.json b/plugins/User/module.json index 0ca546f..4f09014 100644 --- a/plugins/User/module.json +++ b/plugins/User/module.json @@ -3,57 +3,284 @@ "version": "1.0.0", "solves": "user.management", "type": "module", - - "requires": ["database.management", "crypto.services", "cache.redis", "view.rendering", "http.client","validation.rules","mail.delivery","feedback.management"], - "exposes": ["Plugins\\User\\API\\Contracts\\UserServiceContract"], - + "requires": [ + "database.management", + "crypto.services", + "cache.redis", + "view.rendering", + "http.client", + "validation.rules", + "mail.delivery", + "feedback.management", + "audit.trail" + ], + "exposes": [ + "Plugins\\User\\API\\Contracts\\UserServiceContract" + ], "views": "resources/views", - "routes": [ - { "method": "GET", "path": "/admin/users", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@adminIndex", "requires": ["http.pageflow"], "filters": ["auth"] }, - { "method": "GET", "path": "/admin/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@adminShow", "requires": ["http.pageflow"], "filters": ["auth"] }, - { "method": "GET", "path": "/register", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@register", "requires": ["http.pageflow"] }, - { "method": "GET", "path": "/account/profile", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@profile", "requires": ["http.pageflow"], "filters": ["auth"] }, - - { "method": "GET", "path": "/users", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@index" }, - { "method": "GET", "path": "/users/create", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@create" }, - { "method": "GET", "path": "/users/{id}/edit", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@edit" }, - { "method": "GET", "path": "/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@show" }, - { "method": "GET", "path": "/account/settings", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@settings" }, - - { "method": "GET", "path": "/ajx/users", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@index", "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/users", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@register", "filters": ["throttle:10,1"] }, - { "method": "POST", "path": "/ajx/admin/users", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@adminCreate", "filters": ["auth", "throttle:30,1"] }, - { "method": "POST", "path": "/ajx/users/verify", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@verifyEmailByToken", "filters": ["throttle:10,1"] }, - { "method": "GET", "path": "/ajx/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@show", "filters": ["auth"] }, - { "method": "POST", "path": "/ajx/users/{id}/verify-email", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@verifyEmail", "filters": ["auth"] }, - { "method": "PUT", "path": "/ajx/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@update", "filters": ["auth"] }, - { "method": "PATCH", "path": "/ajx/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@update", "filters": ["auth"] }, - { "method": "DELETE", "path": "/ajx/users/{id}", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@destroy", "filters": ["auth"] }, - - { "method": "GET", "path": "/ajx/profile", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showProfile", "filters": ["auth", "tenant"] }, - { "method": "PUT", "path": "/ajx/profile", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updateProfile", "filters": ["auth", "tenant", "throttle:30,1"] }, - - { "method": "GET", "path": "/ajx/preferences", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showPreferences", "filters": ["auth", "tenant"] }, - { "method": "PUT", "path": "/ajx/preferences", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updatePreferences", "filters": ["auth", "tenant", "throttle:30,1"] }, - - { "method": "GET", "path": "/ajx/privacy", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showPrivacy", "filters": ["auth", "tenant"] }, - { "method": "PUT", "path": "/ajx/privacy", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updatePrivacy", "filters": ["auth", "tenant", "throttle:30,1"] }, - - { "method": "GET", "path": "/ajx/notification-preferences", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showNotifications", "filters": ["auth", "tenant"] }, - { "method": "PUT", "path": "/ajx/notification-preferences", "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updateNotifications", "filters": ["auth", "tenant", "throttle:30,1"] } + { + "method": "GET", + "path": "/admin/users", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@adminIndex", + "requires": [ + "http.pageflow" + ], + "filters": [ + "auth" + ] + }, + { + "method": "GET", + "path": "/admin/users/{id}", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@adminShow", + "requires": [ + "http.pageflow" + ], + "filters": [ + "auth" + ] + }, + { + "method": "GET", + "path": "/register", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@register", + "requires": [ + "http.pageflow" + ] + }, + { + "method": "GET", + "path": "/account/profile", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@profile", + "requires": [ + "http.pageflow" + ], + "filters": [ + "auth" + ] + }, + { + "method": "GET", + "path": "/users", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@index" + }, + { + "method": "GET", + "path": "/users/create", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@create" + }, + { + "method": "GET", + "path": "/users/{id}/edit", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@edit" + }, + { + "method": "GET", + "path": "/users/{id}", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@show" + }, + { + "method": "GET", + "path": "/verify-email", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@verifyEmail", + "requires": [ + "http.pageflow" + ] + }, + { + "method": "GET", + "path": "/users/verify", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@verify" + }, + { + "method": "GET", + "path": "/account/settings", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@settings" + }, + { + "method": "GET", + "path": "/ajx/users", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@index", + "filters": [ + "auth" + ] + }, + { + "method": "POST", + "path": "/ajx/users", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@register", + "filters": [ + "throttle:10,1" + ] + }, + { + "method": "POST", + "path": "/ajx/admin/users", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@adminCreate", + "filters": [ + "auth", + "throttle:30,1" + ] + }, + { + "method": "POST", + "path": "/ajx/users/verify", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@verifyEmailByToken", + "filters": [ + "throttle:10,1" + ] + }, + { + "method": "POST", + "path": "/ajx/users/resend-verification", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@resendVerification", + "filters": [ + "throttle:5,10" + ] + }, + { + "method": "GET", + "path": "/ajx/users/{id}", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@show", + "filters": [ + "auth" + ] + }, + { + "method": "POST", + "path": "/ajx/users/{id}/verify-email", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@verifyEmail", + "filters": [ + "auth" + ] + }, + { + "method": "PUT", + "path": "/ajx/users/{id}", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@update", + "filters": [ + "auth" + ] + }, + { + "method": "PATCH", + "path": "/ajx/users/{id}", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@update", + "filters": [ + "auth" + ] + }, + { + "method": "DELETE", + "path": "/ajx/users/{id}", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@destroy", + "filters": [ + "auth" + ] + }, + { + "method": "GET", + "path": "/ajx/profile", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showProfile", + "filters": [ + "auth", + "tenant" + ] + }, + { + "method": "PUT", + "path": "/ajx/profile", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updateProfile", + "filters": [ + "auth", + "tenant", + "throttle:30,1" + ] + }, + { + "method": "GET", + "path": "/ajx/preferences", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showPreferences", + "filters": [ + "auth", + "tenant" + ] + }, + { + "method": "PUT", + "path": "/ajx/preferences", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updatePreferences", + "filters": [ + "auth", + "tenant", + "throttle:30,1" + ] + }, + { + "method": "GET", + "path": "/ajx/privacy", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showPrivacy", + "filters": [ + "auth", + "tenant" + ] + }, + { + "method": "PUT", + "path": "/ajx/privacy", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updatePrivacy", + "filters": [ + "auth", + "tenant", + "throttle:30,1" + ] + }, + { + "method": "GET", + "path": "/ajx/notification-preferences", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showNotifications", + "filters": [ + "auth", + "tenant" + ] + }, + { + "method": "PUT", + "path": "/ajx/notification-preferences", + "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updateNotifications", + "filters": [ + "auth", + "tenant", + "throttle:30,1" + ] + } + ], + "emits": [ + "user.registered", + "user.updated", + "user.deleted" ], - - "emits": ["user.registered", "user.updated", "user.deleted"], "listens": [], - - "documentation": "The User plugin — owns the user.management domain. CRUD + email verification + timing-safe, rate-limited credential verification over the GLOBAL central `users` identity table (identity is centralized, username/email globally unique, repository + outbox pinned to the central connection). Passwords are hashed via crypto.services (bcrypt, rehash-on-login); hashes and remember tokens never cross the API boundary. Writes are optimistic-locked (version); integration events use a transactional outbox drained by `user:outbox:relay`. Enabling publishes config/, database/ (migrations, seeder, factory) and resources/.", - - "commands": ["Plugins\\User\\Infrastructure\\Cli\\RelayUserOutboxCommand"], - + "documentation": "The User plugin \u2014 owns the user.management domain. CRUD + email verification + timing-safe, rate-limited credential verification over the GLOBAL central `users` identity table (identity is centralized, username/email globally unique, repository + outbox pinned to the central connection). Passwords are hashed via crypto.services (bcrypt, rehash-on-login); hashes and remember tokens never cross the API boundary. Writes are optimistic-locked (version); integration events use a transactional outbox drained by `user:outbox:relay`. Enabling publishes config/, database/ (migrations, seeder, factory) and resources/.", + "commands": [ + "Plugins\\User\\Infrastructure\\Cli\\RelayUserOutboxCommand" + ], "config": [ - { "key": "HASH_BCRYPT_COST", "type": "int", "required": false }, - { "key": "USER_BREACH_CHECK", "type": "bool", "required": false }, - { "key": "USER_BREACH_THRESHOLD", "type": "int", "required": false } + { + "key": "HASH_BCRYPT_COST", + "type": "int", + "required": false + }, + { + "key": "USER_BREACH_CHECK", + "type": "bool", + "required": false + }, + { + "key": "USER_BREACH_THRESHOLD", + "type": "int", + "required": false + } ] } diff --git a/plugins/User/resources/views/account/verify.php b/plugins/User/resources/views/account/verify.php new file mode 100644 index 0000000..6a832a7 --- /dev/null +++ b/plugins/User/resources/views/account/verify.php @@ -0,0 +1,78 @@ + +
+

Verify your email

+

+ Paste the verification token from your email below, or follow the link we + sent you. Submits to POST /ajx/users/verify. +

+ + + +
+ + + + +
+ +
+ + Cancel +
+
+
+ + diff --git a/plugins/User/resources/views/layouts/app.php b/plugins/User/resources/views/layouts/app.php index 5362267..546b831 100644 --- a/plugins/User/resources/views/layouts/app.php +++ b/plugins/User/resources/views/layouts/app.php @@ -123,7 +123,7 @@ function flash(message, type = 'ok') { create: (payload) => request('POST', '', payload), update: (id, payload) => request('PUT', '/' + encodeURIComponent(id), payload), remove: (id) => request('DELETE', '/' + encodeURIComponent(id)), - csrf, flash, + request, csrf, flash, }; })(); diff --git a/plugins/User/ui/site/Pages/User/Register.tsx b/plugins/User/ui/site/Pages/User/Register.tsx index b37b335..099fcb0 100644 --- a/plugins/User/ui/site/Pages/User/Register.tsx +++ b/plugins/User/ui/site/Pages/User/Register.tsx @@ -24,6 +24,17 @@ export default function Register() { Create your account + {form.wasSuccessful ? ( +
+

+ Almost there — we've emailed you a verification link. + Follow it, or enter the token to confirm your address. +

+ +
+ ) : (
+ )}

diff --git a/plugins/User/ui/site/Pages/User/VerifyEmail.tsx b/plugins/User/ui/site/Pages/User/VerifyEmail.tsx new file mode 100644 index 0000000..fd1fe5c --- /dev/null +++ b/plugins/User/ui/site/Pages/User/VerifyEmail.tsx @@ -0,0 +1,76 @@ +import { useForm, Head, Link } from "@pageflow/react"; +import { Button } from "@ui/button"; +import { Input } from "@ui/input"; +import { Label } from "@ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@ui/card"; + +// PUBLIC page contributed by the User PLUGIN. The public surface globs +// plugins/*/site/Pages/**, so this resolves as component "User/VerifyEmail". +// Server: UserFlowController@verifyEmail. The emailed link points at +// /verify-email?token=... — the server passes `token` as a prop for prefill. +// Posts to the plugin's own /ajx/users/verify (UserController@verifyEmailByToken). +export default function VerifyEmail({ token = "" }: { token?: string }) { + const form = useForm({ token }); + + function submit(e: React.FormEvent) { + e.preventDefault(); + form.post("/ajx/users/verify"); + } + + return ( + <> + +

+ + + Verify your email + + +

+ Paste the verification token from your email below, or follow the + link we sent you. +

+ {form.wasSuccessful ? ( +
+

+ Your email has been verified. You can now sign in. +

+ +
+ ) : ( +
+
+ + form.setData("token", e.target.value)} + /> + {form.errors.token && ( +

{form.errors.token}

+ )} +
+ +
+ )} +
+
+

+ Need a new account?{" "} + +

+
+ + ); +} diff --git a/projects/Support/Casting/Casts/DatetimeCast.php b/projects/Support/Casting/Casts/DatetimeCast.php index ddab4fe..dba54e3 100644 --- a/projects/Support/Casting/Casts/DatetimeCast.php +++ b/projects/Support/Casting/Casts/DatetimeCast.php @@ -39,7 +39,7 @@ public static function get(mixed $value, array $params = [], ?object $helper = n if ($date === false) { // Fall back to lenient parsing for non-canonical strings. try { - return new DateTimeImmutable($value); + return new DateTimeImmutable(datetime: $value); } catch (\Exception $e) { throw new InvalidArgumentException("Unparsable datetime value: {$value}", 0, $e); } diff --git a/src/Kernel/Loading/OnDemandLoader.php b/src/Kernel/Loading/OnDemandLoader.php index b2ad75c..31c5818 100644 --- a/src/Kernel/Loading/OnDemandLoader.php +++ b/src/Kernel/Loading/OnDemandLoader.php @@ -48,7 +48,17 @@ public function __construct( public function load(DependencyGraph $graph, Request $request): ModuleContainer { - return $this->loadWithIdentity($graph, $request->identity()); + $container = $this->loadWithIdentity($graph, $request->identity()); + + // Expose the client IP for request-scoped services (e.g. the audit trail) + // to attribute an action's origin without threading it through every + // controller. Bound only on the HTTP path — worker jobs have no request. + $ip = $request->ip(); + if ($ip !== null && $ip !== '') { + $container->bind('client.ip', static fn (): string => $ip); + } + + return $container; } /** diff --git a/tests/Unit/Plugins/Auth/ModelUserProviderTenantGateTest.php b/tests/Unit/Plugins/Auth/ModelUserProviderTenantGateTest.php new file mode 100644 index 0000000..7dd6b8a --- /dev/null +++ b/tests/Unit/Plugins/Auth/ModelUserProviderTenantGateTest.php @@ -0,0 +1,67 @@ +users = new FakeUserService(); + $this->users->seed('u1', 'jane', 'jane@example.com'); + $this->users->credentials['jane@example.com:pw'] = 'u1'; + $this->users->rememberTokens['tok'] = 'u1'; + } + + private function provider(): ModelUserProvider + { + return new ModelUserProvider($this->users); + } + + public function test_id_lookup_requests_the_membership_check(): void + { + $p = $this->provider(); + + self::assertNotNull($p->retrieveById('u1')); + self::assertSame([['u1', true]], $this->users->findCalls); + } + + public function test_member_passes_all_lookups(): void + { + $p = $this->provider(); + + self::assertNotNull($p->retrieveById('u1')); + self::assertNotNull($p->retrieveByToken('tok')); + self::assertNotNull($p->retrieveByCredentials(['email' => 'jane@example.com', 'password' => 'pw'])); + } + + public function test_non_member_does_not_exist(): void + { + $this->users->nonMembers = ['u1']; + $p = $this->provider(); + + self::assertNull($p->retrieveById('u1')); + } + + public function test_unknown_user_and_wrong_credentials_return_null(): void + { + $p = $this->provider(); + + self::assertNull($p->retrieveById('nope')); + self::assertNull($p->retrieveByCredentials(['email' => 'jane@example.com', 'password' => 'wrong'])); + } +} diff --git a/tests/Unit/Plugins/Auth/SessionAuthStageRememberTest.php b/tests/Unit/Plugins/Auth/SessionAuthStageRememberTest.php index 3921acc..6fc9df3 100644 --- a/tests/Unit/Plugins/Auth/SessionAuthStageRememberTest.php +++ b/tests/Unit/Plugins/Auth/SessionAuthStageRememberTest.php @@ -8,6 +8,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Container\ModuleContainer; use AlfacodeTeam\PhpServicePlatform\Kernel\Database\TransactionManager; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\DomainEventCollector; +use AlfacodeTeam\PhpServicePlatform\Kernel\Events\EventBus; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort; @@ -22,7 +23,7 @@ use Plugins\User\API\Contracts\UserServiceContract; use Plugins\User\Application\Services\UserService; use Plugins\User\Domain\Entities\User; -use Plugins\User\Infrastructure\Audit\AuditLogger; +use Plugins\Audit\Application\Services\AuditService; use Tests\Unit\Plugins\Auth\Support\FakeSession; use Tests\Unit\Plugins\Auth\Support\RecordingDatabasePort; use Tests\Unit\Plugins\User\Support\FakeCache; @@ -67,10 +68,11 @@ private function container(): ModuleContainer transaction: new TransactionManager(new FakeDatabasePort()), collector: new DomainEventCollector(), outbox: new FakeOutbox(), + eventBus: new EventBus(new CoreContainer()), hasher: new FakeHasher(), identity: Identity::guest(), cache: new FakeCache(), - audit: new AuditLogger('actor', static fn(string $l) => null), + audit: new AuditService(writer: null, sink: static fn(string $l) => null, actorId: 'actor'), ); $auth = new AuthService( diff --git a/tests/Unit/Plugins/Auth/Support/FakeUserService.php b/tests/Unit/Plugins/Auth/Support/FakeUserService.php index b787f23..d39d736 100644 --- a/tests/Unit/Plugins/Auth/Support/FakeUserService.php +++ b/tests/Unit/Plugins/Auth/Support/FakeUserService.php @@ -6,6 +6,7 @@ use Plugins\User\API\Contracts\UserServiceContract; use Plugins\User\API\DTOs\ListUsersQuery; +use Plugins\User\API\DTOs\VerifyEmailResult; use Plugins\User\API\DTOs\RegisterUserDTO; use Plugins\User\API\DTOs\UpdateUserDTO; use Plugins\User\API\DTOs\UserDTO; @@ -32,8 +33,18 @@ public function seed(string $id, string $username, string $email): UserDTO return $dto; } - public function find(string $id): ?UserDTO + /** @var list recorded find() calls: [id, checkMembership] */ + public array $findCalls = []; + + /** @var list ids treated as having NO seat when membership is checked */ + public array $nonMembers = []; + + public function find(string $id, bool $checkMembership = false): ?UserDTO { + $this->findCalls[] = [$id, $checkMembership]; + if ($checkMembership && \in_array($id, $this->nonMembers, true)) { + return null; + } return $this->byId[$id] ?? null; } @@ -52,7 +63,7 @@ public function findByRememberToken(string $token): ?UserDTO /** @var array id => plaintext password set by resetPassword */ public array $resetPasswords = []; - public function findByIdentifier(string $identifier): ?UserDTO + public function findByIdentifier(string $identifier, bool $checkMembership = false): ?UserDTO { foreach ($this->byId as $u) { if ($u->username === $identifier || $u->email === $identifier) { @@ -71,14 +82,15 @@ public function resetPassword(string $userId, string $newPassword): bool return true; } - public function cycleRememberToken(string $userId): string { return 'rotated'; } - public function clearRememberToken(string $userId): void {} + public function cycleRememberToken(string $userId, bool $checkMembership = false): string { return 'rotated'; } + public function clearRememberToken(string $userId, bool $checkMembership = false): void {} public function list(ListUsersQuery $query): UserPage { throw new \BadMethodCallException(); } public function register(RegisterUserDTO $dto): UserDTO { throw new \BadMethodCallException(); } public function registerPublic(RegisterUserDTO $dto): string { throw new \BadMethodCallException(); } - public function verifyEmailByToken(string $token): bool { throw new \BadMethodCallException(); } + public function verifyEmailByToken(string $token): VerifyEmailResult { throw new \BadMethodCallException(); } + public function resendVerification(string $email): ?string { throw new \BadMethodCallException(); } public function update(string $id, UpdateUserDTO $dto): ?UserDTO { throw new \BadMethodCallException(); } public function verifyEmail(string $id, VerifyEmailDTO $dto): ?UserDTO { throw new \BadMethodCallException(); } - public function delete(string $id): bool { throw new \BadMethodCallException(); } + public function delete(string $id, bool $checkMembership = false): bool { throw new \BadMethodCallException(); } } diff --git a/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php b/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php index 535ecb1..4917ece 100644 --- a/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php +++ b/tests/Unit/Plugins/Feedback/FeedbackServiceTest.php @@ -13,7 +13,7 @@ use Plugins\Feedback\API\DTOs\ListFeedbackQuery; use Plugins\Feedback\API\DTOs\SubmitFeedbackDTO; use Plugins\Feedback\Application\Services\FeedbackService; -use Plugins\Feedback\Infrastructure\Audit\AuditLogger; +use Plugins\Audit\Application\Services\AuditService; use Psr\Container\ContainerInterface; use Tests\Unit\Plugins\Feedback\Support\FakeFeedbackStore; use Tests\Unit\Plugins\User\FakeRequest; @@ -34,7 +34,7 @@ private function service(Identity $identity): FeedbackService repository: $this->store, eventBus: new EventBus($this->emptyContainer()), identity: $identity, - audit: new AuditLogger('actor', static fn(string $l) => null), + audit: new AuditService(writer: null, sink: static fn(string $l) => null, actorId: 'actor'), ); } diff --git a/tests/Unit/Plugins/Tenancy/InvitationServiceTest.php b/tests/Unit/Plugins/Tenancy/InvitationServiceTest.php index abe4764..9646029 100644 --- a/tests/Unit/Plugins/Tenancy/InvitationServiceTest.php +++ b/tests/Unit/Plugins/Tenancy/InvitationServiceTest.php @@ -6,7 +6,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use PHPUnit\Framework\TestCase; -use Plugins\Tenancy\Application\Ports\AuditSink; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Tenancy\Application\Ports\InvitationStore; use Plugins\Tenancy\Application\Ports\MembershipWriter; use Plugins\Tenancy\Application\Services\InvitationService; @@ -65,9 +65,9 @@ public function upsertActive(string $userId, string $tenantId, string $role): vo }; } - private function audit(): AuditSink + private function audit(): AuditServiceContract { - return new class implements AuditSink { + return new class implements AuditServiceContract { /** @var list */ public array $actions = []; public function record(string $action, ?string $userId = null, ?string $tenantId = null, array $meta = [], ?string $ip = null): void diff --git a/tests/Unit/Plugins/Tenancy/MembershipServiceTest.php b/tests/Unit/Plugins/Tenancy/MembershipServiceTest.php index fc13b4e..f45705e 100644 --- a/tests/Unit/Plugins/Tenancy/MembershipServiceTest.php +++ b/tests/Unit/Plugins/Tenancy/MembershipServiceTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use Plugins\Auth\API\Contracts\AuthServiceContract; -use Plugins\Tenancy\Application\Ports\AuditSink; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Tenancy\Application\Ports\MembershipReader; use Plugins\Tenancy\Application\Services\MembershipService; use Plugins\Tenancy\Domain\Entities\Membership; @@ -77,7 +77,7 @@ public function hashPassword(string $plain): string { return $plain; } public function verifyPassword(string $plain, string $hash): bool { return $plain === $hash; } }; - $sink = new class($audit) implements AuditSink { + $sink = new class($audit) implements AuditServiceContract { public function __construct(private \ArrayObject $log) {} public function record(string $action, ?string $userId = null, ?string $tenantId = null, array $meta = [], ?string $ip = null): void { diff --git a/tests/Unit/Plugins/Tenancy/TenantHostServiceTest.php b/tests/Unit/Plugins/Tenancy/TenantHostServiceTest.php index 19cba8b..f290dae 100644 --- a/tests/Unit/Plugins/Tenancy/TenantHostServiceTest.php +++ b/tests/Unit/Plugins/Tenancy/TenantHostServiceTest.php @@ -6,7 +6,7 @@ use PHPUnit\Framework\TestCase; use Plugins\Tenancy\API\Contracts\TenantHostRegistryContract; -use Plugins\Tenancy\Application\Ports\AuditSink; +use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Tenancy\Application\Ports\DnsResolver; use Plugins\Tenancy\Application\Ports\TenantHostStore; use Plugins\Tenancy\Application\Services\TenantHostService; @@ -113,9 +113,9 @@ public function forget(string $hostname): void { $this->forgotten[] = $hostname; }; } - private function audit(\ArrayObject $log): AuditSink + private function audit(\ArrayObject $log): AuditServiceContract { - return new class($log) implements AuditSink { + return new class($log) implements AuditServiceContract { public function __construct(private \ArrayObject $log) {} public function record(string $action, ?string $userId = null, ?string $tenantId = null, array $meta = [], ?string $ip = null): void { diff --git a/tests/Unit/Plugins/User/Support/FakeOutbox.php b/tests/Unit/Plugins/User/Support/FakeOutbox.php index 69d0d92..8a5d23c 100644 --- a/tests/Unit/Plugins/User/Support/FakeOutbox.php +++ b/tests/Unit/Plugins/User/Support/FakeOutbox.php @@ -13,9 +13,21 @@ final class FakeOutbox implements OutboxPort /** @var list */ public array $events = []; - public function write(IntegrationEventContract $event): void + /** @var list ids marked dispatched by the service after commit */ + public array $dispatched = []; + + private int $nextId = 0; + + public function write(IntegrationEventContract $event): int { $this->events[] = $event; + + return ++$this->nextId; + } + + public function markDispatched(int $id): void + { + $this->dispatched[] = $id; } /** @return list */ diff --git a/tests/Unit/Plugins/User/Support/FakeUserStore.php b/tests/Unit/Plugins/User/Support/FakeUserStore.php index 472b594..31c6b21 100644 --- a/tests/Unit/Plugins/User/Support/FakeUserStore.php +++ b/tests/Unit/Plugins/User/Support/FakeUserStore.php @@ -91,11 +91,13 @@ public function existsByUsernameOrEmail(string $username, string $email, ?string return false; } - public function insert(User $user): void + public function insert(User &$user): void { if ($this->existsByUsernameOrEmail($user->username(), $user->email())) { throw new DuplicateUserException(); } + $user->setAttribute('updated_at', $user->createdAt()); + $user->syncOriginal(); $this->byId[$user->id()] = $user; } diff --git a/tests/Unit/Plugins/User/UserServiceTest.php b/tests/Unit/Plugins/User/UserServiceTest.php index 83d17d4..7417f55 100644 --- a/tests/Unit/Plugins/User/UserServiceTest.php +++ b/tests/Unit/Plugins/User/UserServiceTest.php @@ -6,6 +6,8 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Database\TransactionManager; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\DomainEventCollector; +use AlfacodeTeam\PhpServicePlatform\Kernel\Events\EventBus; +use Psr\Container\ContainerInterface; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\SecurityException; use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\ValidationException; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; @@ -18,7 +20,7 @@ use Plugins\User\Domain\Entities\User; use Plugins\User\Domain\ValueObjects\Email; use Plugins\User\Domain\ValueObjects\Username; -use Plugins\User\Infrastructure\Audit\AuditLogger; +use Plugins\Audit\Application\Services\AuditService; use Tests\Unit\Plugins\User\Support\FakeCache; use Tests\Unit\Plugins\User\Support\FakeDatabasePort; use Tests\Unit\Plugins\User\Support\FakeHasher; @@ -48,13 +50,22 @@ private function service(Identity $identity): UserService transaction: new TransactionManager(new FakeDatabasePort()), collector: new DomainEventCollector(), outbox: $this->outbox, + eventBus: new EventBus($this->emptyContainer()), hasher: $this->hasher, identity: $identity, cache: $this->cache, - audit: new AuditLogger('actor', static fn(string $l) => null), + audit: new AuditService(writer: null, sink: static fn(string $l) => null, actorId: 'actor'), ); } + private function emptyContainer(): ContainerInterface + { + return new class implements ContainerInterface { + public function get(string $id): mixed { throw new \RuntimeException('no bindings'); } + public function has(string $id): bool { return false; } + }; + } + private function seedUser(string $username = 'janedoe', string $email = 'janedoe@example.com'): User { $user = User::register( diff --git a/tests/Unit/Plugins/User/UserSettingsServiceTest.php b/tests/Unit/Plugins/User/UserSettingsServiceTest.php index bea3690..e99500f 100644 --- a/tests/Unit/Plugins/User/UserSettingsServiceTest.php +++ b/tests/Unit/Plugins/User/UserSettingsServiceTest.php @@ -14,7 +14,7 @@ use Plugins\User\API\DTOs\UpdatePrivacyDTO; use Plugins\User\API\DTOs\UpdateProfileDTO; use Plugins\User\Application\Services\UserSettingsService; -use Plugins\User\Infrastructure\Audit\AuditLogger; +use Plugins\Audit\Application\Services\AuditService; use Plugins\User\Infrastructure\Persistence\UserSettingsRepository; use Tests\Unit\Plugins\User\Support\InMemoryDatabasePort; @@ -32,7 +32,7 @@ private function service(Identity $identity): UserSettingsService return new UserSettingsService( new UserSettingsRepository(new InMemoryDatabasePort()), $identity, - new AuditLogger('actor', static fn(string $l) => null), + new AuditService(writer: null, sink: static fn(string $l) => null, actorId: 'actor'), ); } diff --git a/tools/src/commands/plugins.zig b/tools/src/commands/plugins.zig index e8aad7c..e1a2bd5 100644 --- a/tools/src/commands/plugins.zig +++ b/tools/src/commands/plugins.zig @@ -15,6 +15,7 @@ const util = @import("../lib/util.zig"); const sources = @import("../lib/plugin_sources.zig"); const boot = @import("../lib/plugin_bootstrap.zig"); const assets = @import("../lib/plugin_assets.zig"); +const ui = @import("../lib/plugin_ui.zig"); const deps = @import("../lib/plugin_deps.zig"); const services = @import("../lib/services.zig"); @@ -884,12 +885,16 @@ fn disableOne( return support.text; } -// ── update (re-publish new assets + migrate) ─────────────────────────────────── +// ── update (analyse + sync assets/ui + migrate) ──────────────────────────────── -/// Update already-enabled plugins: publish any NEW assets (migrations, views, -/// config the plugin gained since it was enabled) WITHOUT clobbering existing -/// published files, then run the plugin's pending migrations (a fresh batch in -/// the shared tracking table; tenants too). `only` limits to one plugin. +/// Update already-enabled plugins. Per plugin, ANALYSE every publishable +/// surface (config, database migrations/tenant-template/seeders/factories, +/// resources, ui) against what the project holds, then bring the project in +/// sync: NEW plugin files are published, files whose content DRIFTED from the +/// plugin's version are refreshed (plugin wins), a drifted ui/ mirror is +/// re-synced, and — when any central OR tenant migration was new/changed — +/// the plugin's pending migrations run (a fresh batch in the shared tracking +/// table; tenants too). `only` limits to one plugin. fn updatePlugins( allocator: std.mem.Allocator, io: Io, @@ -922,8 +927,17 @@ fn updatePlugins( const autoload = try services.resolveAutoload(allocator, io, env); + // Discover plugin UIs once so each plugin's frontend mirror can be + // analysed alongside its assets (only when the project has a frontend). + const frontend = try std.fmt.allocPrint(allocator, "{s}/frontend", .{root}); + const has_frontend = util.dirExists(Dir.cwd(), io, frontend); + var ui_plugins: std.ArrayList(ui.UiPlugin) = .empty; + if (has_frontend) try ui.discover(allocator, io, env, root, &ui_plugins); + var touched: usize = 0; var new_total: usize = 0; + var changed_total: usize = 0; + var ui_synced: usize = 0; var matched = false; // Heal the Support-helpers require for enabled plugins that gained (or always @@ -964,41 +978,80 @@ fn updatePlugins( } } - // Detect (and, unless dry-run, copy) only assets not already published. + // Analyse every publishable surface (config, database, resources) + // against the project: NEW files are published, content-drifted files + // are refreshed with the plugin's version. var new_paths: std.ArrayList([]const u8) = .empty; - try assets.publishNewAssets(allocator, io, fp, root, dry_run, &new_paths); + var changed_paths: std.ArrayList([]const u8) = .empty; + try assets.syncAssets(allocator, io, fp, root, dry_run, &new_paths, &changed_paths); + + // Analyse the plugin's ui/ mirror (frontend/plugins/) the same + // way — re-sync when it drifted; a symlinked mirror is always current. + var ui_dirty: ?ui.UiPlugin = null; + for (ui_plugins.items) |up| { + if (!util.eqlIgnoreCase(up.name, e.name)) continue; + if (try ui.mirrorDiffers(allocator, io, root, up)) ui_dirty = up; + break; + } - if (new_paths.items.len == 0) { - prompt.muted(try std.fmt.allocPrint(allocator, "{s}: up to date — no new assets.", .{e.name})); + if (new_paths.items.len == 0 and changed_paths.items.len == 0 and ui_dirty == null) { + prompt.muted(try std.fmt.allocPrint(allocator, "{s}: up to date — config, database, resources and ui all match.", .{e.name})); continue; } touched += 1; new_total += new_paths.items.len; - const verb = if (dry_run) "Would publish" else "Published"; - prompt.ok(try std.fmt.allocPrint(allocator, "{s} {d} new asset(s) for {s}", .{ verb, new_paths.items.len, e.name })); - for (new_paths.items) |p| prompt.muted(try std.fmt.allocPrint(allocator, " + {s}", .{p})); + changed_total += changed_paths.items.len; + if (new_paths.items.len > 0) { + const verb = if (dry_run) "Would publish" else "Published"; + prompt.ok(try std.fmt.allocPrint(allocator, "{s} {d} new asset(s) for {s}", .{ verb, new_paths.items.len, e.name })); + for (new_paths.items) |p| prompt.muted(try std.fmt.allocPrint(allocator, " + {s}", .{p})); + } + if (changed_paths.items.len > 0) { + const verb = if (dry_run) "Would refresh" else "Refreshed"; + prompt.ok(try std.fmt.allocPrint(allocator, "{s} {d} changed asset(s) for {s}", .{ verb, changed_paths.items.len, e.name })); + for (changed_paths.items) |p| prompt.muted(try std.fmt.allocPrint(allocator, " ~ {s}", .{p})); + } + if (ui_dirty) |up| { + if (dry_run) { + ui_synced += 1; + prompt.ok(try std.fmt.allocPrint(allocator, "Would sync ui for {s} → frontend/plugins/{s}", .{ e.name, up.slug })); + } else { + const n = try ui.syncPlugin(allocator, io, root, up, false); + ui_synced += 1; + prompt.ok(try std.fmt.allocPrint(allocator, "Synced ui for {s} → frontend/plugins/{s} ({d} file(s))", .{ e.name, up.slug, n })); + } + } + const migrations_dirty = assets.hasAnyMigrations(new_paths.items) or assets.hasAnyMigrations(changed_paths.items); if (dry_run) { - if (assets.hasMigrations(new_paths.items)) prompt.muted(" + would run pending migrations (central + tenants)"); + if (migrations_dirty) prompt.muted(" + would run pending migrations (central + tenants)"); continue; } - // Merge new paths into the manifest's recorded path list, then migrate - // the plugin's pending migrations (only the new ones apply). + // Merge the synced paths into the manifest's recorded list, then run + // the plugin's pending migrations when any central or tenant migration + // was new/changed (already-applied ones are skipped by name). const existing = (try assets.publishedPathsFor(allocator, io, root, e.name)) orelse &[_][]const u8{}; var merged: std.ArrayList([]const u8) = .empty; for (existing) |p| try merged.append(allocator, p); for (new_paths.items) |p| { if (!containsStr(merged.items, p)) try merged.append(allocator, p); } + for (changed_paths.items) |p| { + if (!containsStr(merged.items, p)) try merged.append(allocator, p); + } try assets.recordPublished(allocator, io, root, e.name, merged.items); - if (assets.hasMigrations(new_paths.items)) { + if (migrations_dirty) { try assets.runPluginMigrations(allocator, io, env, root, autoload, e.name, merged.items); } } + // A re-synced ui/ may have changed its alias/entry — regenerate the glue + // files (manifest.json, index.ts, tsconfig.plugins.json) from the live set. + if (ui_synced > 0 and !dry_run) try ui.writeGlue(allocator, io, root, ui_plugins.items); + if (only.len > 0 and !matched) { prompt.warn(try std.fmt.allocPrint(allocator, "{s} is not enabled in this project.", .{only})); prompt.outro("Nothing to update"); @@ -1006,11 +1059,11 @@ fn updatePlugins( } if (dry_run) { - prompt.outro(try std.fmt.allocPrint(allocator, "Dry run — {d} plugin(s) with {d} new asset(s) · {d} Support require(s) to wire", .{ touched, new_total, wired })); + prompt.outro(try std.fmt.allocPrint(allocator, "Dry run — {d} plugin(s): {d} new · {d} changed asset(s) · {d} ui mirror(s) to sync · {d} Support require(s) to wire", .{ touched, new_total, changed_total, ui_synced, wired })); return 0; } if (wired > 0) try Dir.cwd().writeFile(io, .{ .sub_path = bootstrap, .data = bootstrap_src }); - prompt.outro(try std.fmt.allocPrint(allocator, "Updated {d} plugin(s) · {d} new asset(s) published · {d} Support require(s) wired", .{ touched, new_total, wired })); + prompt.outro(try std.fmt.allocPrint(allocator, "Updated {d} plugin(s) · {d} new · {d} refreshed asset(s) · {d} ui mirror(s) synced · {d} Support require(s) wired", .{ touched, new_total, changed_total, ui_synced, wired })); return 0; } @@ -1458,7 +1511,7 @@ fn printHelp() void { prompt.item("hkm plugins verify [proj]", "audit enabled plugins: wiring, deps + copied assets/views/migrations/configs"); prompt.item("hkm plugins enable [proj]", "wire a plugin into the project bootstrap"); prompt.item("hkm plugins disable [proj]", "remove a plugin from the project bootstrap"); - prompt.item("hkm plugins update [plugin] [proj]", "publish NEW assets of enabled plugin(s) + migrate them; wire any missing Support/helpers.php require"); + prompt.item("hkm plugins update [plugin] [proj]", "analyse enabled plugin(s) vs the project (config/database/resources/ui): publish new + refresh changed assets, re-sync a drifted ui mirror, migrate central + tenant DBs; wire any missing Support/helpers.php require"); prompt.item("hkm plugins upgrade [proj]", "full upgrade after plugins changed: heal new deps, publish/migrate, reconcile plugin SPLITS (moves migration ownership without dropping data)"); prompt.item("hkm plugins create [proj]", "scaffold a new plugin (project, or --kernel)"); prompt.item("hkm plugins delete [proj]", "delete a plugin folder from disk"); @@ -1481,6 +1534,7 @@ fn printHelp() void { prompt.section("Notes"); prompt.item("enable", "resolves requires[] deps (e.g. Tenancy → Database/Auth/User), publishes assets + migrate:run"); prompt.item("disable", "won't orphan dependents (offers to cascade); offers to prune now-unused deps, keeping shared ones"); + prompt.item("update", "the plugin is the source of truth: a project file that drifted from the plugin's copy is OVERWRITTEN (dry-run first to preview); migrations run only when a migration file was new/changed"); prompt.item("upgrade", "split-safe: a migration moved to a new plugin keeps its data; only manifest ownership transfers, no DDL re-runs (aliases: reconcile/migrate)"); prompt.item("create", "scaffolds a complete plugin (config, migration, seeder, factory, view)"); prompt.item("Support helpers", "a plugin's Support/helpers.php is require_once'd in the bootstrap on enable, removed on disable"); diff --git a/tools/src/lib/plugin_assets.zig b/tools/src/lib/plugin_assets.zig index 617906d..f028551 100644 --- a/tools/src/lib/plugin_assets.zig +++ b/tools/src/lib/plugin_assets.zig @@ -70,17 +70,20 @@ pub fn publishAssets( } } -/// Publish only assets that DO NOT yet exist in the project — used by `update` -/// so new migrations/views/config land without clobbering files a user may have -/// customised since the plugin was enabled. The NEW project-relative paths are -/// appended to `out`. Pass `dry_run` to detect without writing. -pub fn publishNewAssets( +/// Analyse a plugin's publishable subtrees (config, database, resources) +/// against the project's published copies, then bring the project in sync: +/// files the plugin gained are published (appended to `new_out`) and files +/// whose CONTENT drifted from the plugin's version are overwritten with the +/// plugin copy (appended to `changed_out`) — the plugin is the source of +/// truth on `update`. Pass `dry_run` to detect without writing. +pub fn syncAssets( allocator: std.mem.Allocator, io: Io, pluginFolder: []const u8, projectRoot: []const u8, dry_run: bool, - out: *std.ArrayList([]const u8), + new_out: *std.ArrayList([]const u8), + changed_out: *std.ArrayList([]const u8), ) !void { const cwd = Dir.cwd(); for (subtrees) |sub| { @@ -92,15 +95,23 @@ pub fn publishNewAssets( for (rels.items) |rel| { const relDest = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ sub, rel }); const dest = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ projectRoot, relDest }); - if (util.fileExists(io, dest)) continue; // already published — leave it + const src = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ srcDir, rel }); + const bytes = cwd.readFileAlloc(io, src, allocator, .limited(16 * 1024 * 1024)) catch continue; + + var kind: enum { new, changed } = .new; + if (cwd.readFileAlloc(io, dest, allocator, .limited(16 * 1024 * 1024)) catch null) |have| { + if (std.mem.eql(u8, have, bytes)) continue; // identical — in sync + kind = .changed; + } if (!dry_run) { - const src = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ srcDir, rel }); - const bytes = cwd.readFileAlloc(io, src, allocator, .limited(16 * 1024 * 1024)) catch continue; if (util.parentOf(dest)) |parent| try cwd.createDirPath(io, parent); try cwd.writeFile(io, .{ .sub_path = dest, .data = bytes }); } - try out.append(allocator, relDest); + switch (kind) { + .new => try new_out.append(allocator, relDest), + .changed => try changed_out.append(allocator, relDest), + } } } } @@ -146,6 +157,14 @@ pub fn hasMigrations(paths: []const []const u8) bool { return false; } +/// True if any path is a migration on EITHER surface — central +/// (database/migrations) or tenant (database/tenant-template). Used by +/// `update` to decide whether a migrate pass is needed at all. +pub fn hasAnyMigrations(paths: []const []const u8) bool { + for (paths) |p| if (isMigrationPath(p)) return true; + return false; +} + // ── manifest (/var/plugin-assets.json) ──────────────────────────────── /// One plugin's published assets plus the migration batch it was applied in. @@ -497,8 +516,10 @@ fn scopedPluginConfig( } /// Run ONLY this plugin's migrations as its own batch: install + run on the -/// CENTRAL database first, then `tenant:migrate --all` for every tenant of the -/// project. Best-effort — a non-zero exit is surfaced as a warning, never fatal. +/// CENTRAL database first, then `tenant:migrate` for every active tenant of +/// the project. The two passes are independent — a plugin shipping only +/// tenant-template migrations still gets its tenant pass, and vice versa. +/// Best-effort — a non-zero exit is surfaced as a warning, never fatal. pub fn runPluginMigrations( allocator: std.mem.Allocator, io: Io, @@ -508,34 +529,48 @@ pub fn runPluginMigrations( folder: []const u8, paths: []const []const u8, ) !void { - const scoped = (try scopedPluginConfig(allocator, io, projectRoot, folder, paths)) orelse return; - defer Dir.cwd().deleteTree(io, scoped.tmp_root) catch {}; - const cfgArg = scoped.config_arg; - // 1. Central database — migrate:run applies ONLY this plugin's pending // migrations (paths are scoped to it) as a NEW batch in the shared // tracking table. We do NOT refresh/drop: a clean enable has nothing // applied yet (disable rolled it back), and dropping tables here would // fail on cross-plugin foreign keys. We capture --json output to learn // the batch number and record it in the plugin-assets manifest. - prompt.muted(try std.fmt.allocPrint(allocator, " migrating {s} on central DB (own batch)…", .{folder})); - _ = try spawnCli(allocator, io, env, projectRoot, autoload, &.{ "migrate:install", "--force", cfgArg }); - const central = try spawnCliCaptureBatch(allocator, io, env, projectRoot, autoload, &.{ "migrate:run", "--force", "--json", cfgArg }); - if (!central.ran or central.code != 0) { - prompt.warn("Central migrate:run returned non-zero — check the DB state."); - } else if (central.applied > 0 and central.batch != null) { - try recordBatch(allocator, io, projectRoot, folder, central.batch.?); - prompt.ok(try std.fmt.allocPrint(allocator, "{s} migrated on central DB — {d} migration(s), batch {d}", .{ folder, central.applied, central.batch.? })); - } else { - prompt.muted(try std.fmt.allocPrint(allocator, " {s}: no new migrations to apply on central DB.", .{folder})); + // scopedPluginConfig is null when the plugin ships no central migrations. + const scoped = try scopedPluginConfig(allocator, io, projectRoot, folder, paths); + defer if (scoped) |s| Dir.cwd().deleteTree(io, s.tmp_root) catch {}; + + if (scoped) |s| { + prompt.muted(try std.fmt.allocPrint(allocator, " migrating {s} on central DB (own batch)…", .{folder})); + _ = try spawnCli(allocator, io, env, projectRoot, autoload, &.{ "migrate:install", "--force", s.config_arg }); + const central = try spawnCliCaptureBatch(allocator, io, env, projectRoot, autoload, &.{ "migrate:run", "--force", "--json", s.config_arg }); + if (!central.ran or central.code != 0) { + prompt.warn("Central migrate:run returned non-zero — check the DB state."); + } else if (central.applied > 0 and central.batch != null) { + try recordBatch(allocator, io, projectRoot, folder, central.batch.?); + prompt.ok(try std.fmt.allocPrint(allocator, "{s} migrated on central DB — {d} migration(s), batch {d}", .{ folder, central.applied, central.batch.? })); + } else { + prompt.muted(try std.fmt.allocPrint(allocator, " {s}: no new migrations to apply on central DB.", .{folder})); + } } - // 2. Every tenant database of this project — same, each tenant records the - // plugin's migrations as its own batch in its own tracking table. - if (scoped.has_tenants) { - prompt.muted(try std.fmt.allocPrint(allocator, " migrating {s} across all tenants (own batch each)…", .{folder})); - const code = try spawnCli(allocator, io, env, projectRoot, autoload, &.{ "tenant:migrate", "--all", cfgArg }); - if (code != 0 and code != 255) prompt.warn("tenant:migrate returned non-zero — check tenant DBs."); + // 2. Tenant databases — tenant:migrate is registry-driven (Tenancy's + // MigrateTenantsCommand): it applies the PROJECT's published + // database/tenant-template against every active tenant, each with its + // own tracking table, and skips tenants already up to date. Run it when + // the plugin ships tenant-template migrations (regardless of central + // ones) or the base let-migrate config declares a tenants resolver. + var ships_tenant_migs = false; + for (paths) |p| { + if (std.mem.startsWith(u8, p, "database/tenant-template/")) { + ships_tenant_migs = true; + break; + } + } + const has_tenants_cfg = if (scoped) |s| s.has_tenants else false; + if (ships_tenant_migs or has_tenants_cfg) { + prompt.muted(try std.fmt.allocPrint(allocator, " migrating tenant template across all active tenants…", .{})); + const code = try spawnCli(allocator, io, env, projectRoot, autoload, &.{"tenant:migrate"}); + if (code != 0 and code != 255) prompt.warn("tenant:migrate returned non-zero — check tenant DBs (is the Tenancy plugin enabled?)."); } } diff --git a/tools/src/lib/plugin_ui.zig b/tools/src/lib/plugin_ui.zig index 7d016f2..69c7d59 100644 --- a/tools/src/lib/plugin_ui.zig +++ b/tools/src/lib/plugin_ui.zig @@ -161,6 +161,29 @@ pub fn syncPlugin( return written; } +/// True when the copied mirror at `frontend/plugins/` differs from the +/// plugin's live `ui/` — missing entirely, missing files, extra files, or any +/// byte-different file. A symlinked mirror never differs (it IS the live tree). +pub fn mirrorDiffers(allocator: std.mem.Allocator, io: Io, projectRoot: []const u8, p: UiPlugin) !bool { + if (p.linked) return false; + const cwd = Dir.cwd(); + const dest = try std.fmt.allocPrint(allocator, "{s}/frontend/plugins/{s}", .{ util.trimSlash(projectRoot), p.slug }); + if (!util.dirExists(cwd, io, dest)) return true; + + var src_rels: std.ArrayList([]const u8) = .empty; + var dest_rels: std.ArrayList([]const u8) = .empty; + try collectFiles(allocator, io, p.uiDir, "", &src_rels); + try collectFiles(allocator, io, dest, "", &dest_rels); + if (src_rels.items.len != dest_rels.items.len) return true; + + for (src_rels.items) |rel| { + const a = cwd.readFileAlloc(io, try std.fmt.allocPrint(allocator, "{s}/{s}", .{ p.uiDir, rel }), allocator, .limited(16 * 1024 * 1024)) catch continue; + const b = cwd.readFileAlloc(io, try std.fmt.allocPrint(allocator, "{s}/{s}", .{ dest, rel }), allocator, .limited(16 * 1024 * 1024)) catch return true; + if (!std.mem.eql(u8, a, b)) return true; + } + return false; +} + /// Recursively collect files under `root`, skipping dotfiles, dev-only subtrees /// (node_modules/tests/dist/…) and non-shippable extensions (.pdf/.map). fn collectFiles(allocator: std.mem.Allocator, io: Io, root: []const u8, prefix: []const u8, out: *std.ArrayList([]const u8)) !void { From 72a99a1e46aec5617de8cd6397ad916f3d47f212 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Mon, 13 Jul 2026 09:14:17 +0300 Subject: [PATCH 030/140] chore(release): v1.0.9 --- CHANGELOG.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b35442..bf36b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.9] - 2026-07-13 + +### Added +- **Audit plugin (`audit.trail`)** — the single owner of the shared central + `audit_log` table. User, Feedback and Tenancy no longer write the table + themselves; they require `audit.trail` and record through the published + `AuditServiceContract` (actor/tenant auto-filled, JSON log line + best-effort + persistence — an audit write never breaks the action it records). + `AuditReaderContract` adds keyset-paginated queries + retention purge. +- **Auth: device sessions, mobile auth and OTP password reset.** New routes: + `GET/DELETE /auth/sessions[/{id}]` + `POST /auth/logout-other-devices` + (device-session listing/revocation backed by the new tenant `auth_sessions` + table), `POST /auth/mobile/{login,register,logout}` (token-first mobile flow), + and `POST /auth/password/{forgot,verify-otp,reset}` (OTP reset via the + CachePort-backed broker, mail optional). New config keys: + `AUTH_SESSION_TTL/REFRESH`, `AUTH_FINGERPRINT_HEADER`, + `AUTH_MOBILE_ACCESS_TTL/AUTOVERIFY`, `AUTH_OTP_TTL`; namespaced `auth::` views. +- **SocialAuth: end-to-end social sign-in.** `GET /auth/social/{driver}` → + provider redirect, `/callback` maps the profile onto a central user (linked + identity → email match → create) opening a platform session or returning a + JWT+refresh pair (`?mode=token`); `POST /auth/social/{driver}/token` verifies + native-SDK tokens (Google access/id token, Apple identity token vs JWKS) for + mobile. Links persist in central `social_identities`. +- **Authorization: policy seeding + enforcement surfaces.** `SeedPolicyCommand` + (CSV policy seed via `config/policy.seed.csv`), HTTP pipeline stages, and + globally autoloaded `Engine/functions.php` helpers. Auth now requires + `authorization.policy` and resolves roles through the new `RoleResolver`. +- **Tenancy: `var/tenants.json` default tenant for the CLI.** `tenant:create` + records the provisioned tenant (last created = default) so `tenant:delete` / + `tenant:host:add` work without `--tenant`/`--slug`; new `tenant:remember` + backfills pre-existing tenants (`--slug`, `--tenant`, `--all`, or interactive). + Hints are re-validated against the registry and stale entries self-drop. +- **`hkm plugins update` — full analyse + sync.** Update now compares every + publishable surface (config, database migrations/tenant-template/seeders/ + factories, resources, ui) byte-for-byte against the project: publishes NEW + files, refreshes content-drifted files (plugin wins), re-syncs a drifted + plugin ui mirror (+ glue regen), and runs migrations when a central OR tenant + migration changed. Dry-run previews the full analysis. +- **Kernel: request-scoped `client.ip` binding.** `OnDemandLoader::load()` + exposes the client IP in the request container so request-scoped services + (e.g. the audit trail) can attribute an action's origin without threading it + through controllers. + +### Changed +- **Tenant-membership is now part of the user fetch.** `UserServiceContract` + id-based operations take a `checkMembership` flag; `ModelUserProvider` fetches + with membership enforced, so on a tenant-scoped request a user without an + active seat is indistinguishable from a non-existent user. +- **Tenant-scoped tables moved to `database/tenant-template/`.** Auth + (personal access tokens, refresh tokens, auth_sessions), Authorization + (casbin_rule) and OAuth2 (oauth_* tables) migrations are now provisioned per + tenant database instead of the central DB. +- **User outbox refactored into GDA layers** (`OutboxRelayService` + + `OutboxRepository` replace the Infrastructure outbox writer/relay pair) and + the email-verification flow gained a full page path (`VerifyEmailResult`, + `account/verify` view, `VerifyEmail.tsx` site page). +- **`hkm` tenant migrate passes are now independent.** A plugin shipping only + tenant-template migrations still gets its tenant pass, and `tenant:migrate` + is triggered by shipped tenant-template files (registry-driven) instead of a + `tenants` key in `config/let-migrate.php`. +- `modules/let-migrate` bumped: safe transaction handling around implicit DDL + commits; unsigned integers, CHECK constraints and table options in the schema + builder. + +### Fixed +- **`AuthUserProxy::withSecurity()`/`withAccessToken()` dropped `joinedAt`**, + shifting constructor arguments and throwing a `TypeError` on every session / + JWT guard resolution. + ## [1.0.8] - 2026-07-11 ### Fixed From c2055439b323ed5d175f12a25f8cb860e471e305 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Tue, 14 Jul 2026 05:45:38 +0300 Subject: [PATCH 031/140] feat(auth,session,tenancy,user): display identity, previous-page login redirect, selection decomposition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Identity gains best-effort display fields (username/email/fullName/avatarUrl): filled from the central user store at issuance (lazy resolver — breaks the AuthService -> UserService -> MembershipService container cycle behind the 30s login hang), carried as OIDC claims on JWTs and as session keys, rebuilt statelessly by JwtAuthLayer / SessionAuthStage / recaller resurrection. - Session StartSessionStage records the last eligible page view (GET+2xx, HTML or Pageflow; auth/OAuth/API/asset paths exempt, SESSION_PREVIOUS_EXEMPT extends) under StartSessionStage::PREVIOUS_URL — single source of truth. Login redirects: explicit redirectTo (query/body) -> recorded page (pulled one-time) -> '/', all open-redirect-guarded; SocialAuth callback honours it. - Tenant selection decomposed: MembershipService verifies + audits only (returns TenantSummary, no Auth dependency); TenantController composes the Auth mint (tnt/roles/name via the new published TenantProfileReaderContract). - User publishes TenantProfileReaderContract (tenant user_profiles display reads, pinned or per-call resolver mode); UserDTO gains fullName/avatarUrl/ permissions; UserServiceContract::find() gains isAuth for issuance lookups. - Tenant-scoped auth data (Auth PAT + device sessions, Audit audit_log, OAuth2 tables, SocialAuth identities) now resolves the per-request DatabasePort; migrations moved to database/tenant-template/. - ApiController/ViewController compose InteractsWithSession (sessionPull et al). - Fixes: readonly UserDTO property fatal, nullable tenantId in recaller flow. --- plugins/Audit/Provider.php | 10 +- plugins/Audit/database/migrations/.gitkeep | 0 ...26_06_22_000005_create_audit_log_table.php | 0 .../API/Contracts/AuthServiceContract.php | 15 ++- .../Auth/Application/Auth/AuthUserProxy.php | 14 ++- .../Auth/Application/Auth/GuardAccessor.php | 2 +- .../Application/Auth/ModelUserProvider.php | 2 +- .../Application/Auth/StatefulSessionGuard.php | 8 +- .../Auth/Application/Services/AuthService.php | 79 ++++++++++++- .../Auth/Drivers/SessionDriver.php | 1 + .../Controllers/SessionAuthController.php | 41 ++++++- .../Controllers/TransientTokenController.php | 5 + .../Http/Stages/SessionAuthStage.php | 28 ++++- .../PersonalAccessTokenRepository.php | 20 +++- plugins/Auth/Provider.php | 12 +- plugins/Auth/README.md | 21 ++++ plugins/Auth/Security/JwtAuthLayer.php | 6 + .../Security/PersonalAccessTokenLayer.php | 6 +- ..._04_000002_create_refresh_tokens_table.php | 4 +- ...7_12_000001_create_auth_sessions_table.php | 4 +- plugins/Authorization/Provider.php | 3 +- plugins/Feedback/database/migrations/.gitkeep | 0 plugins/OAuth2/Provider.php | 10 +- plugins/Pageflow/Http/PageflowAuth.php | 1 + plugins/Pageflow/Http/PageflowPage.php | 1 + .../Infrastructure/Http/StartSessionStage.php | 110 +++++++++++++++++- plugins/Session/module.json | 3 +- .../Http/Controllers/SocialAuthController.php | 28 ++++- plugins/SocialAuth/Provider.php | 3 +- .../SocialAuth/database/migrations/.gitkeep | 0 ..._000001_create_social_identities_table.php | 0 .../Contracts/MembershipServiceContract.php | 13 ++- plugins/Tenancy/API/DTOs/TenantSelection.php | 7 +- .../Services/MembershipService.php | 39 +++---- .../Http/Controllers/TenantController.php | 37 +++++- plugins/Tenancy/Provider.php | 19 ++- plugins/Tenancy/README.md | 11 +- .../Contracts/TenantProfileReaderContract.php | 32 +++++ .../API/Contracts/UserServiceContract.php | 2 +- plugins/User/API/DTOs/UserDTO.php | 46 +++++--- .../Services/TenantProfileProvisioner.php | 110 +++++++++++++++--- .../User/Application/Services/UserService.php | 34 +++++- plugins/User/Domain/Entities/User.php | 22 +++- plugins/User/Domain/Entities/UserProfile.php | 6 + plugins/User/Provider.php | 26 ++++- plugins/User/README.md | 15 +++ projects/Http/Controllers/ApiController.php | 2 + projects/Http/Controllers/ViewController.php | 2 + src/Kernel/Security/Identity.php | 14 ++- .../Plugins/Auth/Support/FakeAuthService.php | 2 +- .../Plugins/Auth/Support/FakeUserService.php | 2 +- .../Session/PreviousPageRecordingTest.php | 99 ++++++++++++++++ .../Plugins/Tenancy/MembershipServiceTest.php | 36 +----- 53 files changed, 856 insertions(+), 157 deletions(-) create mode 100644 plugins/Audit/database/migrations/.gitkeep rename plugins/Audit/database/{migrations => tenant-template}/2026_06_22_000005_create_audit_log_table.php (100%) create mode 100644 plugins/Feedback/database/migrations/.gitkeep create mode 100644 plugins/SocialAuth/database/migrations/.gitkeep rename plugins/SocialAuth/database/{migrations => tenant-template}/2026_07_12_000001_create_social_identities_table.php (100%) create mode 100644 plugins/User/API/Contracts/TenantProfileReaderContract.php create mode 100644 tests/Unit/Plugins/Session/PreviousPageRecordingTest.php diff --git a/plugins/Audit/Provider.php b/plugins/Audit/Provider.php index 5297b05..76afd7e 100644 --- a/plugins/Audit/Provider.php +++ b/plugins/Audit/Provider.php @@ -53,7 +53,7 @@ public function register(ModuleContainer $container): void { // Write side: persistence seam behind the audit service (central conn). $container->bindInternal(AuditWriter::class, static fn (ModuleContainer $c): AuditWriter => - new AuditTrail(self::central($c))); + new AuditTrail($c->make(DatabasePort::class))); // Published write contract — the ONE way any plugin records an action. // Auto-fills actor (Identity) and tenant (Tenancy's `tenant.current` @@ -82,7 +82,7 @@ public function register(ModuleContainer $container): void // Published read/query contract for control-plane admin surfaces. $container->bind(AuditReaderContract::class, static fn (ModuleContainer $c): AuditReaderContract => - new AuditLogRepository(self::central($c))); + new AuditLogRepository($c->make(DatabasePort::class))); } public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void @@ -90,9 +90,5 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke // No pipeline hooks or subscriptions — a pure infrastructure domain. } - /** The CENTRAL connection (owns the shared `audit_log` table). */ - private static function central(ModuleContainer $c): DatabasePort - { - return $c->make(DatabaseConnectionManagerContract::class)->default(); - } + } diff --git a/plugins/Audit/database/migrations/.gitkeep b/plugins/Audit/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/Audit/database/migrations/2026_06_22_000005_create_audit_log_table.php b/plugins/Audit/database/tenant-template/2026_06_22_000005_create_audit_log_table.php similarity index 100% rename from plugins/Audit/database/migrations/2026_06_22_000005_create_audit_log_table.php rename to plugins/Audit/database/tenant-template/2026_06_22_000005_create_audit_log_table.php diff --git a/plugins/Auth/API/Contracts/AuthServiceContract.php b/plugins/Auth/API/Contracts/AuthServiceContract.php index 815e7c5..3fd3dd0 100644 --- a/plugins/Auth/API/Contracts/AuthServiceContract.php +++ b/plugins/Auth/API/Contracts/AuthServiceContract.php @@ -42,7 +42,12 @@ public function tokensFor(string $userId): array; * `user_tenants` table at tenant-selection time. `tenant` is accepted as a * legacy alias. * - * @param array{roles?:list,permissions?:list,tnt?:string,tenant?:string} $claims + * Display-identity claims (OIDC names) may be supplied: `preferred_username`, + * `email`, `name` (full name from the tenant user_profiles table). When + * username/email are omitted they are filled from the central user record; + * `name` is only minted by tenant-aware callers (tenant selection). + * + * @param array{roles?:list,permissions?:list,tnt?:string,tenant?:string,preferred_username?:string,email?:string,name?:string} $claims */ public function issueJwt(string $userId, array $claims = [], int $ttlSeconds = 3600): string; @@ -74,6 +79,10 @@ public function revokePersonalAccessToken(string $id): void; * SessionAuthStage can rebuild an Identity on subsequent requests. Call AFTER * verifying credentials (e.g. UserServiceContract::verifyCredentials). * + * Display identity: username/email are filled from the central user record + * when omitted; $fullName (first + last from the tenant user_profiles table) + * is stored as supplied — pass it when tenant context is known. + * * @param list $roles * @param list $permissions */ @@ -83,6 +92,10 @@ public function startSession( array $roles = [], array $permissions = [], string $tenantId = '', + string $username = '', + string $email = '', + string $fullName = '', + ?string $avatarUrl = null, ): void; /** Tear down a web/AJAX login: clear attributes and rotate the session id. */ diff --git a/plugins/Auth/Application/Auth/AuthUserProxy.php b/plugins/Auth/Application/Auth/AuthUserProxy.php index 2a640ee..32d159e 100644 --- a/plugins/Auth/Application/Auth/AuthUserProxy.php +++ b/plugins/Auth/Application/Auth/AuthUserProxy.php @@ -34,12 +34,14 @@ private function __construct( private string $username, private string $email, private array $roles, - private array $permissions, + private array $permissions, private string $tenantId, private string $tokenType, private string $joinedAt, private ?AuthServiceContract $tokensService = null, private ?TokenDTO $accessToken = null, + private string $fullName = '', + private ?string $avatarUrl = null, ) {} /** @@ -65,6 +67,8 @@ public static function fromUser( tokenType: $tokenType, tokensService: $tokensService, joinedAt: $user->joinedAt ?? "", + fullName: $user->fullName, + avatarUrl: $user->avatarUrl, ); } @@ -88,6 +92,8 @@ public function withSecurity(array $roles, array $permissions, string $tenantId, $this->joinedAt, $this->tokensService, $this->accessToken, + $this->fullName, + $this->avatarUrl, ); } @@ -105,6 +111,8 @@ public function withAccessToken(TokenDTO $token): self $this->joinedAt, $this->tokensService, $token, + $this->fullName, + $this->avatarUrl, ); } @@ -183,6 +191,10 @@ public function identity(): Identity roles: $this->roles, permissions: $this->permissions, tokenType: $this->tokenType, + username: $this->username, + email: $this->email, + fullName: $this->fullName, + avatarUrl: $this->avatarUrl, ); } } diff --git a/plugins/Auth/Application/Auth/GuardAccessor.php b/plugins/Auth/Application/Auth/GuardAccessor.php index 4e5f54e..84e7165 100644 --- a/plugins/Auth/Application/Auth/GuardAccessor.php +++ b/plugins/Auth/Application/Auth/GuardAccessor.php @@ -39,7 +39,7 @@ public function __construct( private readonly Request $request, private readonly ?StatefulGuard $stateful = null, ) {} - + /** * The WRITE-side guard (attempt/login/logout/…), when this guard is * stateful. Null for token-style guards. diff --git a/plugins/Auth/Application/Auth/ModelUserProvider.php b/plugins/Auth/Application/Auth/ModelUserProvider.php index 80ca00b..f0686f0 100644 --- a/plugins/Auth/Application/Auth/ModelUserProvider.php +++ b/plugins/Auth/Application/Auth/ModelUserProvider.php @@ -48,7 +48,7 @@ public function retrieveById(string $id): ?Authenticatable return null; } - return $this->proxy($this->users->find($id, true)); + return $this->proxy($this->users->find($id, true,true)); } public function retrieveByToken(string $rememberToken): ?Authenticatable diff --git a/plugins/Auth/Application/Auth/StatefulSessionGuard.php b/plugins/Auth/Application/Auth/StatefulSessionGuard.php index 399ca7f..96a898e 100644 --- a/plugins/Auth/Application/Auth/StatefulSessionGuard.php +++ b/plugins/Auth/Application/Auth/StatefulSessionGuard.php @@ -17,7 +17,7 @@ use Plugins\Auth\Infrastructure\Http\Stages\SessionAuthStage; use Plugins\Cookie\Infrastructure\CookieJar; use Plugins\User\API\Contracts\UserServiceContract; - + /** * StatefulSessionGuard — the interactive login/logout guard. * @@ -106,7 +106,7 @@ public function user(): ?Authenticatable public function attempt(array $credentials = [], bool $remember = false): bool { $user = $this->provider->retrieveByCredentials($credentials); - + $this->lastAttempted = $user; if ($user === null) { @@ -171,6 +171,10 @@ public function login(Authenticatable $user, bool $remember = false): void $this->session->put(AuthService::SESSION_ROLES, $identity->roles); $this->session->put(AuthService::SESSION_PERMISSIONS, $identity->permissions); $this->session->put(AuthService::SESSION_TENANT, $identity->tenantId); + $this->session->put(AuthService::SESSION_USERNAME, $identity->username); + $this->session->put(AuthService::SESSION_EMAIL, $identity->email); + $this->session->put(AuthService::SESSION_NAME, $identity->fullName); + $this->session->put(AuthService::SESSION_AVATAR, $identity->avatarUrl); // Bind the session to this device: fingerprint + auth_sessions row. if ($this->devices !== null && $this->request !== null) { diff --git a/plugins/Auth/Application/Services/AuthService.php b/plugins/Auth/Application/Services/AuthService.php index 5ac5364..2925d00 100644 --- a/plugins/Auth/Application/Services/AuthService.php +++ b/plugins/Auth/Application/Services/AuthService.php @@ -31,6 +31,10 @@ final class AuthService implements AuthServiceContract public const SESSION_ROLES = 'auth.roles'; public const SESSION_PERMISSIONS = 'auth.permissions'; public const SESSION_TENANT = 'auth.tenant'; + public const SESSION_USERNAME = 'auth.username'; + public const SESSION_EMAIL = 'auth.email'; + public const SESSION_NAME = 'auth.name'; + public const SESSION_AVATAR = 'auth.avatar'; /** * @param string $jwtSecret HMAC secret (HS*). Required for symmetric algos. @@ -51,8 +55,13 @@ public function __construct( private readonly ?string $jwtKid = null, private readonly ?\Plugins\Auth\Application\Auth\RoleResolver $roles = null, private readonly ?TransactionManager $transaction = null, // central-connection tx + // Central user store — fills username/email display claims at issuance + // so the stateless verification layers can rebuild a full Identity. + // LAZY (fn(): UserServiceContract): resolving it eagerly recurses — + // AuthService → UserService → MembershipService → AuthService. + private readonly ?\Closure $users = null, ) { - } + } /** Asymmetric algorithms sign with a private key rather than a shared secret. */ private function isAsymmetric(): bool @@ -83,6 +92,19 @@ public function issueJwt(string $userId, array $claims = [], int $ttlSeconds = 3 $claims['permissions'] = $resolved['permissions']; } + // Display-identity claims (OIDC names) — ride on the signed token so the + // stateless JwtAuthLayer can rebuild a full Identity without a DB read. + // username/email come from the central user record when the caller did + // not supply them; `name` (first + last) lives in the TENANT + // user_profiles table, so only a tenant-aware caller (tenant selection) + // can mint it — best-effort, never blocks issuance. + [$username, $email] = $this->displayIdentity( + $userId, + (string) ($claims['preferred_username'] ?? ''), + (string) ($claims['email'] ?? ''), + ); + $fullName = (string) ($claims['name'] ?? ''); + $now = time(); $payload = [ 'sub' => $userId, @@ -96,6 +118,16 @@ public function issueJwt(string $userId, array $claims = [], int $ttlSeconds = 3 'jti' => bin2hex(random_bytes(16)), ]; + if ($username !== '') { + $payload['preferred_username'] = $username; + } + if ($email !== '') { + $payload['email'] = $email; + } + if ($fullName !== '') { + $payload['name'] = $fullName; + } + // Registered claims for issuer/audience binding (verified by JwtAuthLayer). if ($this->jwtIssuer !== null && $this->jwtIssuer !== '') { $payload['iss'] = $this->jwtIssuer; @@ -152,6 +184,10 @@ public function startSession( array $roles = [], array $permissions = [], string $tenantId = '', + string $username = '', + string $email = '', + string $fullName = '', + ?string $avatarUrl = null, ): void { // RBAC enrichment: when the caller passes no explicit roles/permissions // and Authorization is loaded, resolve the user's effective grants so the @@ -162,6 +198,10 @@ public function startSession( $permissions = $resolved['permissions']; } + // Display-identity enrichment — same policy as issueJwt(): fill + // username/email from the central record when not supplied. + [$username, $email] = $this->displayIdentity($userId, $username, $email); + // Session-fixation defence: rotate the id whenever the privilege level // changes (anonymous → authenticated). Existing flash data is preserved. $session->regenerate(); @@ -170,6 +210,10 @@ public function startSession( $session->put(self::SESSION_ROLES, array_values($roles)); $session->put(self::SESSION_PERMISSIONS, array_values($permissions)); $session->put(self::SESSION_TENANT, $tenantId); + $session->put(self::SESSION_USERNAME, $username); + $session->put(self::SESSION_EMAIL, $email); + $session->put(self::SESSION_NAME, $fullName); + $session->put(self::SESSION_AVATAR, $avatarUrl); } public function endSession(SessionPort $session): void @@ -199,6 +243,39 @@ public function verifyPassword(string $plain, string $hash): bool return $this->hasher->check($plain, $hash); } + /** + * Fill username/email from the central user record when the caller did not + * supply them. Best-effort: a lookup failure never blocks credential + * issuance — the credential simply carries no display claims. + * + * @return array{0:string,1:string} [username, email] + */ + private function displayIdentity(string $userId, string $username, string $email): array + { + if (($username !== '' && $email !== '') || $this->users === null || $userId === '') { + return [$username, $email]; + } + + try { + /** @var ?\Plugins\User\API\Contracts\UserServiceContract $service */ + $service = ($this->users)(); + // isAuth: issuance happens while the request Identity is still + // guest — the self-or-permission check would reject the lookup. + $user = $service?->find($userId, false, true); + } catch (\Throwable) { + return [$username, $email]; + } + + if ($user === null) { + return [$username, $email]; + } + + return [ + $username !== '' ? $username : $user->username, + $email !== '' ? $email : $user->email, + ]; + } + /** * Bracket a unit of work in a transaction on the central auth connection. * Nesting-aware (TransactionManager), and a straight pass-through when no diff --git a/plugins/Auth/Infrastructure/Auth/Drivers/SessionDriver.php b/plugins/Auth/Infrastructure/Auth/Drivers/SessionDriver.php index 78f7a03..d824492 100644 --- a/plugins/Auth/Infrastructure/Auth/Drivers/SessionDriver.php +++ b/plugins/Auth/Infrastructure/Auth/Drivers/SessionDriver.php @@ -35,6 +35,7 @@ public function resolve(Request $request, GuardContext $context): ?Authenticatab return null; } + $user = $context->provider->retrieveById($userId); if (!$user instanceof AuthUserProxy) { return $user; diff --git a/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php b/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php index 47b1195..07eec9e 100644 --- a/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php +++ b/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php @@ -7,6 +7,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; use Plugins\Auth\Application\Ports\Authenticatable; use Plugins\Auth\Application\Services\DeviceSessionService; +use Plugins\Session\Infrastructure\Http\StartSessionStage; use Project\Http\Controllers\ApiController; use Project\Http\Controllers\Concerns\InteractsWithAuthManager; @@ -66,7 +67,42 @@ public function login(): Response return Response::unauthorized('Invalid credentials.'); } - return $this->ok(['user' => $this->shape($guard->user())]); + // Where to send the user after sign-in, first match wins: + // 1. an explicit `redirectTo` on the login request itself (query or + // body — e.g. /auth/login?redirectTo=/billing), + // 2. the previous page recorded by the Session plugin's + // StartSessionStage — PULLED (one-time) either way, so the + // fulfilled intent never goes stale, + // 3. '/'. + // Both candidates pass the same open-redirect guard. + $previous = $this->sessionPull(StartSessionStage::PREVIOUS_URL); + $redirect = $this->safeRedirect($request->input('redirectTo')) + ?? $this->safeRedirect($previous) + ?? '/'; + + // Browser form POST → real redirect; AJAX/SPA callers get the target in + // the payload and navigate client-side. + if (!$request->expectsJson()) { + return Response::redirect($redirect); + } + + return $this->ok(['user' => $this->shape($guard->user()), 'redirectTo' => $redirect]); + } + + /** + * Validate a redirect candidate into a safe INTERNAL target, or null when + * it is unusable. Accepts only a relative path ('/…'); rejects + * protocol-relative ('//…'), backslash tricks and absolute URLs — the + * open-redirect guard for both the request param and the session value. + */ + private function safeRedirect(mixed $candidate): ?string + { + if (!is_string($candidate) || $candidate === '' || $candidate[0] !== '/' + || str_starts_with($candidate, '//') || str_starts_with($candidate, '/\\')) { + return null; + } + + return $candidate; } public function logout(): Response @@ -89,6 +125,9 @@ public function me(): Response return $this->ok([ 'userId' => $identity->userId, + 'username' => $identity->username, + 'email' => $identity->email, + 'fullName' => $identity->fullName, 'tenantId' => $identity->tenantId, 'roles' => $identity->roles, 'permissions' => $identity->permissions, diff --git a/plugins/Auth/Infrastructure/Http/Controllers/TransientTokenController.php b/plugins/Auth/Infrastructure/Http/Controllers/TransientTokenController.php index ead5951..b9f2b52 100644 --- a/plugins/Auth/Infrastructure/Http/Controllers/TransientTokenController.php +++ b/plugins/Auth/Infrastructure/Http/Controllers/TransientTokenController.php @@ -43,6 +43,11 @@ public function refresh(): Response 'roles' => $identity->roles, 'permissions' => $identity->permissions, 'tnt' => $identity->tenantId, + // Carry the session's display identity onto the minted JWT so + // the SPA's Bearer requests keep username/email/fullName. + 'preferred_username' => $identity->username, + 'email' => $identity->email, + 'name' => $identity->fullName, ], self::TTL_SECONDS, ); diff --git a/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php b/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php index f12547a..69c2f3b 100644 --- a/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php +++ b/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php @@ -47,16 +47,19 @@ public function handle(Request $request, callable $next): Response { // A token already authenticated this request — do not override it. $existing = $request->identity(); + if ($existing !== null && !$existing->isGuest()) { return $next($request); } $container = $request->container(); + if ($container === null || !$container->has(SessionPort::class)) { return $next($request); } $session = $container->make(SessionPort::class); + if (!$session instanceof SessionPort) { return $next($request); } @@ -72,6 +75,8 @@ public function handle(Request $request, callable $next): Response return $next($request); } + + // Fingerprint + device-session validation (old __DEV__ semantics): a // request that can't reproduce the login fingerprint, or whose server- // side session row was revoked/expired, loses the session outright. @@ -91,6 +96,10 @@ public function handle(Request $request, callable $next): Response roles: $this->stringList($session->get(AuthService::SESSION_ROLES, [])), permissions: $this->stringList($session->get(AuthService::SESSION_PERMISSIONS, [])), tokenType: 'session', + username: (string) $session->get(AuthService::SESSION_USERNAME, ''), + email: (string) $session->get(AuthService::SESSION_EMAIL, ''), + fullName: (string) $session->get(AuthService::SESSION_NAME, ''), + avatarUrl: (string) $session->get(AuthService::SESSION_AVATAR, ''), ); return $next($this->attach($request, $container, $identity)); @@ -158,9 +167,16 @@ private function fromRecaller(Request $request, $container, SessionPort $session // the remember token + cookie so this recaller can't be replayed. $auth = $container->make(AuthServiceContract::class); if ($auth instanceof AuthServiceContract) { - $auth->startSession($session, $user->id); + $auth->startSession($session, $user->id, username: $user->username, email: $user->email, fullName: $user->fullName, avatarUrl: $user->avatarUrl, tenantId: $user->tenantId ?? '', roles: $user->roles, permissions: $user->permissions); } else { $session->put(AuthService::SESSION_USER, $user->id); + $session->put(AuthService::SESSION_USERNAME, $user->username); + $session->put(AuthService::SESSION_EMAIL, $user->email); + $session->put(AuthService::SESSION_AVATAR, $user->avatarUrl); + $session->put(AuthService::SESSION_NAME, $user->fullName); + $session->put(AuthService::SESSION_TENANT, $user->tenantId ?? ''); + $session->put(AuthService::SESSION_ROLES, $user->roles); + $session->put(AuthService::SESSION_PERMISSIONS, $user->permissions); } $fresh = $users->cycleRememberToken($user->id); @@ -172,10 +188,14 @@ private function fromRecaller(Request $request, $container, SessionPort $session return $this->attach($request, $container, new Identity( userId: $user->id, - tenantId: '', - roles: [], - permissions: [], + tenantId: $user->tenantId ?? '', + roles: $user->roles, + permissions: $user->permissions, tokenType: 'session', + username: $user->username, + email: $user->email, + fullName: $user->fullName, + avatarUrl: $user->avatarUrl, )); } diff --git a/plugins/Auth/Infrastructure/Persistence/PersonalAccessTokenRepository.php b/plugins/Auth/Infrastructure/Persistence/PersonalAccessTokenRepository.php index 305b0df..ac5cbef 100644 --- a/plugins/Auth/Infrastructure/Persistence/PersonalAccessTokenRepository.php +++ b/plugins/Auth/Infrastructure/Persistence/PersonalAccessTokenRepository.php @@ -18,6 +18,7 @@ final class PersonalAccessTokenRepository public function __construct( private readonly DatabasePort $db, private readonly string $table = 'personal_access_tokens', + private readonly string $usersTable = 'users', ) { } @@ -58,13 +59,22 @@ public function store( * Look up an UNEXPIRED token by its hash. Expired tokens are treated as * absent so a stale credential can never authenticate. * - * @return array{id:string,user_id:string,abilities:list}|null + * The owning user's display identity (username/email from the central + * `users` table) rides on the same query via a LEFT JOIN, so the security + * layer can build a full Identity without touching the DatabasePort itself. + * A missing user row degrades to '' — display data never gates auth. + * + * @return array{id:string,user_id:string,abilities:list,username:string,email:string}|null */ public function findByHash(string $tokenHash): ?array { try { $row = $this->db->queryOne( - "SELECT id, user_id, abilities, expires_at FROM {$this->table} WHERE token_hash = :hash", + "SELECT t.id, t.user_id, t.abilities, t.expires_at, + u.username AS owner_username, u.email AS owner_email + FROM {$this->table} t + LEFT JOIN {$this->usersTable} u ON u.user_id = t.user_id + WHERE t.token_hash = :hash", ['hash' => $tokenHash] ); } catch (\PDOException $e) { @@ -75,6 +85,10 @@ public function findByHash(string $tokenHash): ?array return null; } + $username = (string) ($row['owner_username'] ?? ''); + $email = (string) ($row['owner_email'] ?? ''); + unset($row['owner_username'], $row['owner_email']); + $token = PersonalAccessToken::reconstitute($row); // Enforce expiry in PHP (driver-portable — no NOW() dialect branching). @@ -86,6 +100,8 @@ public function findByHash(string $tokenHash): ?array 'id' => $token->id(), 'user_id' => $token->userId(), 'abilities' => $token->abilities(), + 'username' => $username, + 'email' => $email, ]; } diff --git a/plugins/Auth/Provider.php b/plugins/Auth/Provider.php index 0e77d15..1d2eb4e 100644 --- a/plugins/Auth/Provider.php +++ b/plugins/Auth/Provider.php @@ -78,7 +78,7 @@ public function register(ModuleContainer $container): void // Central connection — tokens belong to the control plane, not a // tenant DB, so resolve the ConnectionManager default rather than // the per-request (tenant-rebound) DatabasePort. - $c->make(DatabaseConnectionManagerContract::class)->default(), + $c->make(DatabasePort::class), env('AUTH_PAT_TABLE') ?: 'personal_access_tokens', ) ); @@ -87,7 +87,7 @@ public function register(ModuleContainer $container): void $container->bindInternal(\Plugins\Auth\Infrastructure\Persistence\DeviceSessionRepository::class, static fn(ModuleContainer $c) => new \Plugins\Auth\Infrastructure\Persistence\DeviceSessionRepository( - $c->make(DatabaseConnectionManagerContract::class)->default(), + $c->make(DatabasePort::class), ) ); @@ -129,6 +129,14 @@ public function register(ModuleContainer $container): void jwtKid: env('JWT_KID') ?: null, roles: $c->make(\Plugins\Auth\Application\Auth\RoleResolver::class), transaction: $c->make('auth.transaction'), + // Fills the display-identity claims (preferred_username/email) + // on issued credentials when the caller doesn't supply them. + // LAZY closure — an eager make() recurses: AuthService → + // UserService → MembershipService → AuthService (bind() has no + // cycle guard, so it loops until max_execution_time). + users: $c->has(\Plugins\User\API\Contracts\UserServiceContract::class) + ? static fn() => $c->make(\Plugins\User\API\Contracts\UserServiceContract::class) + : null, ) ); diff --git a/plugins/Auth/README.md b/plugins/Auth/README.md index 6c5e7eb..071a1ce 100644 --- a/plugins/Auth/README.md +++ b/plugins/Auth/README.md @@ -40,6 +40,10 @@ final readonly class Identity { public array $roles; // list public array $permissions; // list (PAT abilities / OAuth scopes) public string $tokenType; // 'jwt' | 'api_key' | 'session' | 'none' + public string $username; // display identity — best-effort, '' when unknown + public string $email; + public string $fullName; // tenant user_profiles; tenant-scoped credentials only + public ?string $avatarUrl; public function hasRole(string $r): bool; public function hasPermission(string $p): bool; // honours '*' public function isGuest(): bool; @@ -202,6 +206,23 @@ With no live session, `SessionAuthStage` validates it by the token's SHA-256 has (`UserServiceContract::findByRememberToken`), re-opens the session, and **rotates** the token + cookie (single-use window). Logout clears both. +**Post-login redirect.** The Session plugin's `StartSessionStage` records the +last eligible page view (GET + 2xx, HTML or Pageflow page object; auth/OAuth/ +API/asset paths exempt — extend with `SESSION_PREVIOUS_EXEMPT`) under +`StartSessionStage::PREVIOUS_URL`. On successful login the redirect target is: +an explicit `redirectTo` on the request (query/body) → the recorded previous +page (pulled one-time) → `/`. Browser POSTs get a 302; AJAX callers get +`redirectTo` in the JSON payload. Every candidate passes an open-redirect +guard (relative `/…` paths only). SocialAuth's web callback honours the same +recorded page. + +**Display identity.** `AuthService` fills `username`/`email` from the central +user store at issuance when the caller didn't supply them; they ride as OIDC +claims (`preferred_username`, `email`, `name`) on JWTs and as session keys, so +verification layers rebuild a full `Identity` without a DB read. The user-store +dependency is a lazy closure — never resolve `UserServiceContract` eagerly in +the AuthService factory (container cycle). + ## 7. Personal access tokens (self-service) First-party user API keys — **not** OAuth clients, **not** used by session login. diff --git a/plugins/Auth/Security/JwtAuthLayer.php b/plugins/Auth/Security/JwtAuthLayer.php index 8791c1d..fa36e95 100644 --- a/plugins/Auth/Security/JwtAuthLayer.php +++ b/plugins/Auth/Security/JwtAuthLayer.php @@ -122,6 +122,12 @@ public function check(Request $request): SecurityVerdict roles: array_values((array) ($claims['roles'] ?? [])), permissions: array_values((array) ($claims['permissions'] ?? [])), tokenType: 'jwt', + // Display-identity claims minted by AuthService::issueJwt() (OIDC + // names). `name` is first + last from the tenant user_profiles table, + // present only on tenant-scoped tokens. + username: (string) ($claims['preferred_username'] ?? ''), + email: (string) ($claims['email'] ?? ''), + fullName: (string) ($claims['name'] ?? ''), ); return SecurityVerdict::allow($request->withIdentity($identity)); diff --git a/plugins/Auth/Security/PersonalAccessTokenLayer.php b/plugins/Auth/Security/PersonalAccessTokenLayer.php index df5641c..eb9b58c 100644 --- a/plugins/Auth/Security/PersonalAccessTokenLayer.php +++ b/plugins/Auth/Security/PersonalAccessTokenLayer.php @@ -59,13 +59,17 @@ public function check(Request $request): SecurityVerdict // Empty tenant = unscoped (central connection), consistent with the JWT // layer and TenantContextStage. A PAT is a control-plane credential; it - // does not silently bind to a tenant DB. + // does not silently bind to a tenant DB — so fullName stays empty (it + // lives in the tenant user_profiles table). The repository's findByHash + // joins the owner's username/email onto the record (all SQL stays there). $identity = new Identity( userId: $record['user_id'], tenantId: '', roles: [], permissions: $record['abilities'], tokenType: 'api_key', + username: (string) ($record['username'] ?? ''), + email: (string) ($record['email'] ?? ''), ); return SecurityVerdict::allow($request->withIdentity($identity)); diff --git a/plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php b/plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php index 43b2501..c62a724 100644 --- a/plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php +++ b/plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php @@ -29,7 +29,8 @@ public function up(SchemaBuilderInterface $schema): void $t->id(); $t->char('token_id', 31); $t->char('family_id', 31)->comment('rotation lineage for reuse detection'); - $t->char('user_id', 31); + $t->char('user_id', 31) + ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); $t->char('token_hash', 64)->comment('SHA-256 of the refresh token — never store raw'); $t->char('tenant_id', 31)->nullable()->comment('scope hint for the tnt claim; not re-verified'); $t->string('device', 191)->nullable()->comment('UA / device label'); @@ -44,7 +45,6 @@ public function up(SchemaBuilderInterface $schema): void $t->index(['user_id', 'revoked_at'], 'idx_user_active'); $t->index(['family_id'], 'idx_family'); - $t->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); $t->engine('InnoDB'); $t->charset('utf8mb4'); diff --git a/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php b/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php index cbee61a..aed4cf0 100644 --- a/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php +++ b/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php @@ -25,7 +25,8 @@ public function up(SchemaBuilderInterface $schema): void $schema->create('auth_sessions', static function ($t) { $t->id(); $t->char('session_id', 32)->comment('public id (list/revoke API) — not the token'); - $t->char('user_id', 31); + $t->char('user_id', 31) + ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); $t->char('token_hash', 64)->comment('SHA-256 of the session token — never store raw'); $t->char('fingerprint', 64)->nullable()->comment('SHA-256 device fingerprint captured at login'); $t->string('ip', 45)->nullable(); @@ -39,7 +40,6 @@ public function up(SchemaBuilderInterface $schema): void $t->unique(['token_hash'], 'uniq_token_hash'); $t->index(['user_id', 'revoked_at'], 'idx_user_active'); - $t->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); $t->engine('InnoDB'); $t->charset('utf8mb4'); diff --git a/plugins/Authorization/Provider.php b/plugins/Authorization/Provider.php index b05ee4f..35b49c2 100644 --- a/plugins/Authorization/Provider.php +++ b/plugins/Authorization/Provider.php @@ -10,6 +10,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Cli\CliPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; use Plugins\Authorization\API\Contracts\AuthorizationServiceContract; use Plugins\Authorization\Application\Services\AuthorizationService; use Plugins\Authorization\Engine\Enforcer; @@ -52,7 +53,7 @@ public function register(ModuleContainer $container): void // policies are visible to runtime enforcement. $container->bindInternal(DatabasePolicyAdapter::class, static fn(ModuleContainer $c) => new DatabasePolicyAdapter( - $c->make(DatabaseConnectionManagerContract::class)->default(), + $c->make(DatabasePort::class), env('AUTHZ_POLICY_TABLE') ?: 'casbin_rule', ) ); diff --git a/plugins/Feedback/database/migrations/.gitkeep b/plugins/Feedback/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/OAuth2/Provider.php b/plugins/OAuth2/Provider.php index a07aef6..65061fa 100644 --- a/plugins/OAuth2/Provider.php +++ b/plugins/OAuth2/Provider.php @@ -89,15 +89,15 @@ public function register(ModuleContainer $container): void { // ── persistence (central connection — control plane) ────────────────── $container->bind(ClientStore::class, static fn(ModuleContainer $c) => - new ClientRepository(self::central($c))); + new ClientRepository($c->make(DatabasePort::class))); $container->bindInternal(AuthCodeStore::class, static fn(ModuleContainer $c) => - new AuthCodeRepository(self::central($c))); + new AuthCodeRepository($c->make(DatabasePort::class))); $container->bindInternal(RefreshTokenStore::class, static fn(ModuleContainer $c) => - new RefreshTokenRepository(self::central($c))); + new RefreshTokenRepository($c->make(DatabasePort::class))); $container->bindInternal(ScopeStore::class, static fn(ModuleContainer $c) => - new ScopeRepository(self::central($c))); + new ScopeRepository($c->make(DatabasePort::class ))); $container->bindInternal(DeviceCodeStore::class, static fn(ModuleContainer $c) => - new DeviceCodeRepository(self::central($c))); + new DeviceCodeRepository($c->make(DatabasePort::class ))); $container->bindInternal(ScopeValidator::class, static fn(ModuleContainer $c) => new ScopeValidator($c->make(ScopeStore::class))); diff --git a/plugins/Pageflow/Http/PageflowAuth.php b/plugins/Pageflow/Http/PageflowAuth.php index 8de54b7..812c573 100644 --- a/plugins/Pageflow/Http/PageflowAuth.php +++ b/plugins/Pageflow/Http/PageflowAuth.php @@ -42,6 +42,7 @@ public static function project(?callable $projector): void */ public static function resolve(?Identity $identity): array { + // dd($identity); if (self::$projector !== null) { return (self::$projector)($identity); } diff --git a/plugins/Pageflow/Http/PageflowPage.php b/plugins/Pageflow/Http/PageflowPage.php index 43edd0e..b7f9515 100644 --- a/plugins/Pageflow/Http/PageflowPage.php +++ b/plugins/Pageflow/Http/PageflowPage.php @@ -42,6 +42,7 @@ public function toArray(): array public function toJson(): string { + return json_encode($this->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } diff --git a/plugins/Session/Infrastructure/Http/StartSessionStage.php b/plugins/Session/Infrastructure/Http/StartSessionStage.php index 2db299f..16319ee 100644 --- a/plugins/Session/Infrastructure/Http/StartSessionStage.php +++ b/plugins/Session/Infrastructure/Http/StartSessionStage.php @@ -19,12 +19,36 @@ * SessionPort the Session provider bound into it) is available. It: * 1. loads the session keyed by the incoming session cookie, * 2. runs the rest of the pipeline, - * 3. saves the session and writes the session cookie onto the response. + * 3. remembers the visited page (login "previous page" redirect target), + * 4. saves the session and writes the session cookie onto the response. * * Stateless across requests — all state lives in the per-request Store instance. */ final class StartSessionStage implements HttpStageContract { + /** + * Session key holding the last non-auth page visited (relative path + + * query) — the SINGLE source of truth for this key. The Auth/SocialAuth + * login flows PULL it (one-time) to redirect the user back after a + * successful sign-in. + */ + public const PREVIOUS_URL = 'auth.previous_url'; + + /** Longest URL worth remembering — anything bigger is dropped, not truncated. */ + private const PREVIOUS_URL_MAX = 2048; + + /** + * Path prefixes that must never become a post-login redirect target: the + * auth surface itself (login/logout/registration/password/OAuth) and the + * JSON surfaces (/api, /ajx) that are not browser pages. Extend per + * deployment with SESSION_PREVIOUS_EXEMPT (comma-separated prefixes). + */ + private const PREVIOUS_URL_EXEMPT = [ + '/auth', '/oauth', '/api', '/ajx', + '/login', '/logout', '/register', '/password', + '/verify-email', '/users/verify', + ]; + public function handle(Request $request, callable $next): Response { $container = $request->container(); @@ -56,6 +80,10 @@ public function handle(Request $request, callable $next): Response $response = $next($request); + // Remember the page for the post-login redirect — BEFORE the persistence + // check below, so the write is part of this request's save. + $this->rememberPreviousPage($request, $session, $response); + // Lazy persistence: a fresh visitor that never used the session leaves no // server file (and, for the cookie driver, no payload) and gets no cookie — // stateless traffic (APIs, bots) stays clean. @@ -87,6 +115,86 @@ public function handle(Request $request, callable $next): Response ); } + /** + * Record the current page into the session so a later login can send the + * user straight back to it. Only real, successful page views qualify: + * - GET requests with a 2xx response, + * - an HTML navigation OR a Pageflow page object (X-Pageflow response + * header — SPA visits count as page views too), + * - never auth/registration/OAuth/API paths (the DESTINATION of the login + * flow, not a place to return to) and never static-asset-looking paths. + * + * Only the RELATIVE path + query is stored (never scheme/host); the login + * flows re-validate the value before redirecting (relative path only), so + * the session can never become an open redirect. + */ + private function rememberPreviousPage(Request $request, SessionPort $session, Response $response): void + { + if ($request->method() !== 'GET') { + return; + } + + $path = $request->path(); + if ($this->isExemptPath($path) || $this->looksLikeAsset($path)) { + return; + } + + $status = $response->getStatusCode(); + if ($status < 200 || $status >= 300) { + return; + } + + // A page view is an HTML navigation or a Pageflow page object; other + // JSON/file responses on GET routes are data fetches, not pages. + $contentType = (string) $response->headers->get('Content-Type'); + if (!str_contains($contentType, 'text/html') && !$response->headers->has('X-Pageflow')) { + return; + } + + // Path + query only, rebuilt from the parsed params (never the raw URI, + // which can throw on a malformed Host header). + $url = $path; + $query = http_build_query($request->queryAll()); + if ($query !== '') { + $url .= '?' . $query; + } + + if (\strlen($url) <= self::PREVIOUS_URL_MAX && $session->get(self::PREVIOUS_URL) !== $url) { + $session->put(self::PREVIOUS_URL, $url); + } + } + + private function isExemptPath(string $path): bool + { + foreach ([...self::PREVIOUS_URL_EXEMPT, ...$this->configuredExemptions()] as $prefix) { + if ($path === $prefix || str_starts_with($path, $prefix . '/')) { + return true; + } + } + + return false; + } + + /** A path with a file extension is an asset (/favicon.ico, /app.css), not a page. */ + private function looksLikeAsset(string $path): bool + { + return (bool) preg_match('/\.[a-z0-9]{2,5}$/i', $path); + } + + /** @return list */ + private function configuredExemptions(): array + { + $raw = (string) (env('SESSION_PREVIOUS_EXEMPT') ?? ''); + if ($raw === '') { + return []; + } + + return array_values(array_filter(array_map( + static fn (string $p): string => '/' . ltrim(trim($p), '/'), + explode(',', $raw), + ), static fn (string $p): bool => $p !== '/')); + } + /** * Whether to flag the session cookie Secure. SESSION_SECURE forces it on/off; * unset (or "auto") follows the request scheme so dev over plain HTTP still works. diff --git a/plugins/Session/module.json b/plugins/Session/module.json index 3632822..90ed003 100644 --- a/plugins/Session/module.json +++ b/plugins/Session/module.json @@ -29,6 +29,7 @@ { "key": "SESSION_COOKIE_FINGERPRINT", "type": "string", "required": false }, { "key": "SESSION_COOKIE_MAX_BYTES", "type": "int", "required": false }, { "key": "SESSION_COOKIE_REQUIRE_AUTH", "type": "string", "required": false }, - { "key": "SESSION_COOKIE_REQUIRE_ENCRYPTION", "type": "string", "required": false } + { "key": "SESSION_COOKIE_REQUIRE_ENCRYPTION", "type": "string", "required": false }, + { "key": "SESSION_PREVIOUS_EXEMPT", "type": "string", "required": false } ] } diff --git a/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php b/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php index 8a41b99..e1b4e76 100644 --- a/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php +++ b/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php @@ -10,6 +10,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort; use Plugins\Auth\API\Contracts\AuthServiceContract; use Plugins\Auth\API\Contracts\RefreshTokenServiceContract; +use Plugins\Session\Infrastructure\Http\StartSessionStage; use Plugins\SocialAuth\API\Contracts\SocialAuthServiceContract; use Plugins\SocialAuth\Application\Services\SocialLoginService; use Plugins\SocialAuth\Infrastructure\Gateways\ProviderTokenGateway; @@ -71,10 +72,22 @@ public function callback(string $driver): Response return $this->ok(['user' => $user->toArray(), 'tokens' => $this->issueTokenPair($user)]); } - // Web flow: open a platform session and send the browser on its way. - $this->auth->startSession($this->session, $user->id); - - return Response::redirect($this->successRedirect); + // Web flow: open a platform session and send the browser on its way — + // back to the page recorded by the Session plugin's StartSessionStage + // when there is one (validated: relative path only — open-redirect + // guard), else the configured default. + $this->auth->startSession($this->session, $user->id, username: $user->username, email: $user->email); + + $previous = $this->session->pull(StartSessionStage::PREVIOUS_URL); + $target = is_string($previous) + && $previous !== '' + && $previous[0] === '/' + && !str_starts_with($previous, '//') + && !str_starts_with($previous, '/\\') + ? $previous + : $this->successRedirect; + + return Response::redirect($target); } public function token(string $driver): Response @@ -111,7 +124,12 @@ private function issueTokenPair(UserDTO $user): array ); return [ - 'accessToken' => $this->auth->issueJwt($user->id, [], $this->accessTtl), + // Display claims passed explicitly — the user record is already in + // hand, so AuthService skips its central-lookup enrichment. + 'accessToken' => $this->auth->issueJwt($user->id, [ + 'preferred_username' => $user->username, + 'email' => $user->email, + ], $this->accessTtl), 'tokenType' => 'Bearer', 'expiresAt' => time() + $this->accessTtl, 'refreshToken' => $refresh->token, diff --git a/plugins/SocialAuth/Provider.php b/plugins/SocialAuth/Provider.php index 90280d8..83782d4 100644 --- a/plugins/SocialAuth/Provider.php +++ b/plugins/SocialAuth/Provider.php @@ -10,6 +10,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Cli\CliPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HttpClientPort; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort; use Plugins\Auth\API\Contracts\AuthServiceContract; @@ -70,7 +71,7 @@ public function register(ModuleContainer $container): void // Provider-account → user links (central — control-plane table). $container->bindInternal(SocialIdentityRepository::class, static fn(ModuleContainer $c) => new SocialIdentityRepository( - $c->make(DatabaseConnectionManagerContract::class)->default(), + $c->make(DatabasePort::class), ) ); diff --git a/plugins/SocialAuth/database/migrations/.gitkeep b/plugins/SocialAuth/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugins/SocialAuth/database/migrations/2026_07_12_000001_create_social_identities_table.php b/plugins/SocialAuth/database/tenant-template/2026_07_12_000001_create_social_identities_table.php similarity index 100% rename from plugins/SocialAuth/database/migrations/2026_07_12_000001_create_social_identities_table.php rename to plugins/SocialAuth/database/tenant-template/2026_07_12_000001_create_social_identities_table.php diff --git a/plugins/Tenancy/API/Contracts/MembershipServiceContract.php b/plugins/Tenancy/API/Contracts/MembershipServiceContract.php index bf78bff..7eaf564 100644 --- a/plugins/Tenancy/API/Contracts/MembershipServiceContract.php +++ b/plugins/Tenancy/API/Contracts/MembershipServiceContract.php @@ -4,7 +4,6 @@ namespace Plugins\Tenancy\API\Contracts; -use Plugins\Tenancy\API\DTOs\TenantSelection; use Plugins\Tenancy\API\DTOs\TenantSummary; /** @@ -14,6 +13,10 @@ * This is the authority the Auth flow consults to turn an authenticated (but * unscoped) user into a tenant-scoped session. It NEVER trusts a client-supplied * tenant id without confirming an active membership in the central database. + * + * Control plane ONLY: it verifies seats and audits — it never mints + * credentials. The caller (TenantController) takes the verified seat returned + * by selectTenant() and asks the Auth module to issue the tenant-scoped token. */ interface MembershipServiceContract { @@ -37,11 +40,11 @@ public function isActiveMember(string $userId, string $tenantId): bool; public function activeMember(string $userId, string $tenantId): ?TenantSummary; /** - * Select a tenant: verify active membership + routable tenant, then mint a - * tenant-scoped access token (the `tnt` claim). Records `tenant.switch` in - * the audit log. + * Select a tenant: verify active membership + routable tenant and record + * `tenant.switch` in the audit log. Returns the verified seat; the caller + * mints the tenant-scoped token (`tnt` claim) from it via the Auth module. * * @throws \Plugins\Tenancy\Domain\Exceptions\NotAMemberException (→ 403) */ - public function selectTenant(string $userId, string $tenantId, ?string $ip = null): TenantSelection; + public function selectTenant(string $userId, string $tenantId, ?string $ip = null): TenantSummary; } diff --git a/plugins/Tenancy/API/DTOs/TenantSelection.php b/plugins/Tenancy/API/DTOs/TenantSelection.php index 9859d28..32de9e6 100644 --- a/plugins/Tenancy/API/DTOs/TenantSelection.php +++ b/plugins/Tenancy/API/DTOs/TenantSelection.php @@ -6,9 +6,10 @@ /** * Result of selecting a tenant: a freshly minted tenant-scoped access token - * (the `tnt` claim is now set) plus the resolved role and expiry. The client - * sends this token on subsequent requests; TenantContextStage routes them to the - * tenant database. + * (the `tnt` claim is now set) plus the resolved role and expiry. Built by the + * HTTP boundary (TenantController) after MembershipService verifies the seat + * and the Auth module mints the token. The client sends this token on + * subsequent requests; TenantContextStage routes them to the tenant database. */ final readonly class TenantSelection { diff --git a/plugins/Tenancy/Application/Services/MembershipService.php b/plugins/Tenancy/Application/Services/MembershipService.php index 07d5af9..5735991 100644 --- a/plugins/Tenancy/Application/Services/MembershipService.php +++ b/plugins/Tenancy/Application/Services/MembershipService.php @@ -4,23 +4,25 @@ namespace Plugins\Tenancy\Application\Services; -use Plugins\Auth\API\Contracts\AuthServiceContract; use Plugins\Tenancy\API\Contracts\MembershipServiceContract; -use Plugins\Tenancy\API\DTOs\TenantSelection; use Plugins\Tenancy\API\DTOs\TenantSummary; use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Tenancy\Application\Ports\MembershipReader; use Plugins\Tenancy\Domain\Exceptions\NotAMemberException; /** - * MembershipService — the tenant-selection flow. + * MembershipService — the tenant-selection authority (control plane ONLY). * - * Turns an authenticated (unscoped) user into a tenant-scoped session: - * 1. list the tenants they may switch into (active seats, active tenants), - * 2. on selection, RE-VERIFY the membership against central `user_tenants` - * (never trust a client-supplied tenant id), then mint a tenant-scoped - * access token via the Auth module (`tnt` claim set), - * 3. audit the switch. + * Answers exactly one question: does this user hold an active, routable seat + * in this tenant? It lists seats for the picker, RE-VERIFIES the membership + * against central `user_tenants` on selection (never trusting a client-supplied + * tenant id), and audits the switch/denial. + * + * It does NOT mint credentials — tenancy is not authentication. The HTTP + * boundary (TenantController) takes the verified seat returned here and asks + * the Auth module to issue the tenant-scoped token. Keeping Auth out of this + * service also keeps the container graph acyclic + * (AuthService → UserService → MembershipService). * * The re-verification on selection — and the per-request re-check that the Auth * layer/TenantContextStage performs — is what makes a revoked seat lose access @@ -30,9 +32,7 @@ final class MembershipService implements MembershipServiceContract { public function __construct( private readonly MembershipReader $memberships, - private readonly AuthServiceContract $auth, private readonly AuditServiceContract $audit, - private readonly int $tokenTtl = 3600, ) {} public function myTenants(string $userId): array @@ -57,28 +57,17 @@ public function activeMember(string $userId, string $tenantId): ?TenantSummary : null; } - public function selectTenant(string $userId, string $tenantId, ?string $ip = null): TenantSelection + public function selectTenant(string $userId, string $tenantId, ?string $ip = null): TenantSummary { $membership = $this->memberships->find($userId, $tenantId); - + if ($membership === null || !$membership->isRoutable()) { $this->audit->record('tenant.switch_denied', $userId, $tenantId, [], $ip); throw NotAMemberException::for($userId, $tenantId); } - $token = $this->auth->issueJwt( - $userId, - ['tnt' => $tenantId, 'roles' => [$membership->role]], - $this->tokenTtl, - ); - $this->audit->record('tenant.switch', $userId, $tenantId, ['role' => $membership->role], $ip); - return new TenantSelection( - token: $token, - tenantId: $tenantId, - role: $membership->role, - expiresIn: $this->tokenTtl, - ); + return TenantSummary::fromMembership($membership); } } diff --git a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantController.php b/plugins/Tenancy/Infrastructure/Http/Controllers/TenantController.php index c818519..2baa8be 100644 --- a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantController.php +++ b/plugins/Tenancy/Infrastructure/Http/Controllers/TenantController.php @@ -5,13 +5,22 @@ namespace Plugins\Tenancy\Infrastructure\Http\Controllers; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; +use Plugins\Auth\API\Contracts\AuthServiceContract; use Plugins\Tenancy\API\Contracts\MembershipServiceContract; +use Plugins\Tenancy\API\DTOs\TenantSelection; use Plugins\Tenancy\Domain\Exceptions\NotAMemberException; +use Plugins\User\API\Contracts\TenantProfileReaderContract; use Project\Http\Controllers\ApiController; /** * Thin HTTP boundary for the tenant-selection flow — DTO/service → Response. * + * COMPOSITION POINT: MembershipService verifies the seat (control plane) and + * the Auth module mints the tenant-scoped token — tenancy is not + * authentication, so the two published contracts are composed HERE rather than + * inside MembershipService (which would also cycle the container graph: + * AuthService → UserService → MembershipService → AuthService). + * * RequestAware (via ApiController): actions take route params only; the active * request is $this->resolveRequest(). The user id always comes from the verified * Identity, never from the request body — a client cannot act as another user. @@ -20,6 +29,12 @@ final class TenantController extends ApiController { public function __construct( private readonly MembershipServiceContract $memberships, + private readonly AuthServiceContract $auth, + // User's published tenant-profile reader — fills the `name` claim + // (Identity.fullName). Optional and never-throwing: the token simply + // carries no full name when it is absent or the profile is unreachable. + private readonly ?TenantProfileReaderContract $profiles = null, + private readonly int $tokenTtl = 3600, ) {} /** GET /ajx/me/tenants — the tenant picker for the authenticated user. */ @@ -44,7 +59,7 @@ public function select(string $tenantId): Response } try { - $selection = $this->memberships->selectTenant( + $seat = $this->memberships->selectTenant( $identity->userId, $tenantId, $this->resolveRequest()->ip(), @@ -53,6 +68,26 @@ public function select(string $tenantId): Response return $this->forbidden('You are not an active member of this tenant.'); } + $token = $this->auth->issueJwt( + $identity->userId, + [ + 'tnt' => $tenantId, + 'roles' => [$seat->role], + // Full name lives in the TENANT user_profiles table — selection + // is the one place tenant context is known at mint time. + // username/email are filled centrally by AuthService. + 'name' => $this->profiles?->fullName($identity->userId, $tenantId) ?? '', + ], + $this->tokenTtl, + ); + + $selection = new TenantSelection( + token: $token, + tenantId: $tenantId, + role: $seat->role, + expiresIn: $this->tokenTtl, + ); + return $this->ok($selection->toArray()); } } diff --git a/plugins/Tenancy/Provider.php b/plugins/Tenancy/Provider.php index 187fd9a..acb305a 100644 --- a/plugins/Tenancy/Provider.php +++ b/plugins/Tenancy/Provider.php @@ -189,16 +189,27 @@ public function register(ModuleContainer $container): void // Tenancy records through its published AuditServiceContract instead of // writing `audit_log` itself — see requires: ["audit.trail"]. + // Control plane only — verifies seats + audits. No Auth dependency: + // token minting lives in TenantController, which keeps the container + // graph acyclic (AuthService → UserService → MembershipService). $container->bind(MembershipServiceContract::class, static fn($c): MembershipServiceContract => new MembershipService( memberships: $c->make(MembershipReader::class), - auth: $c->make(AuthServiceContract::class), audit: $c->make(AuditServiceContract::class), - tokenTtl: self::intEnv('TENANCY_TOKEN_TTL', 3600), )); + // The HTTP boundary composes the verified seat with the Auth module + // (token mint) and User's tenant-profile reader (the `name` claim from + // the tenant user_profiles row — the User plugin owns that table's SQL). $container->bindInternal(TenantController::class, static fn($c): TenantController => - new TenantController($c->make(MembershipServiceContract::class))); + new TenantController( + memberships: $c->make(MembershipServiceContract::class), + auth: $c->make(AuthServiceContract::class), + profiles: $c->has(\Plugins\User\API\Contracts\TenantProfileReaderContract::class) + ? $c->make(\Plugins\User\API\Contracts\TenantProfileReaderContract::class) + : null, + tokenTtl: self::intEnv('TENANCY_TOKEN_TTL', 3600), + )); // ── tenant administration (control-plane CRUD) ─────────────────────── // Provisions/updates/de-provisions tenants over HTTP — the JSON twin of @@ -268,7 +279,7 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke // stays OUTER of the cookie-flush stage so the remembered-tenant cookie it // queues is still written to the response. All after.load hooks run before // the dedicated RouteFilterStage regardless of priority. - $http->hook('after.load', TenantContextStage::class, priority: 23); + $http->hook('after.load', TenantContextStage::class, priority: 10); // Reusable declarative guard: a route that touches tenant-only tables // opts in with "filters": ["auth", "tenant"] to fail clean (409) when no diff --git a/plugins/Tenancy/README.md b/plugins/Tenancy/README.md index d73af61..e0d7a48 100644 --- a/plugins/Tenancy/README.md +++ b/plugins/Tenancy/README.md @@ -65,11 +65,16 @@ POST /ajx/tenants/{tenantId}/select → re-mint a tenant-scoped token ``` `selectTenant()` **re-verifies** the membership against central `user_tenants` -(never trusts a client-supplied tenant id), then mints a token via the Auth -module with the `tnt` claim set, and audits `tenant.switch`: +(never trusts a client-supplied tenant id), audits `tenant.switch`, and returns +the verified seat (`TenantSummary`). Tenancy is control plane ONLY — it does +NOT mint credentials: `TenantController` composes the seat with the Auth +module (`AuthServiceContract::issueJwt`, `tnt` claim + `roles` + the `name` +claim read via User's published `TenantProfileReaderContract`) and builds the +response: ```php -$selection = $memberships->selectTenant($identity->userId, $tenantId, $request->ip()); +$seat = $memberships->selectTenant($identity->userId, $tenantId, $request->ip()); +// controller: issueJwt(userId, ['tnt' => ..., 'roles' => [$seat->role], 'name' => ...]) // → { token, tokenType: "Bearer", tenantId, role, expiresIn } ``` diff --git a/plugins/User/API/Contracts/TenantProfileReaderContract.php b/plugins/User/API/Contracts/TenantProfileReaderContract.php new file mode 100644 index 0000000..915f7d3 --- /dev/null +++ b/plugins/User/API/Contracts/TenantProfileReaderContract.php @@ -0,0 +1,32 @@ +getMembership()?->role; + $fullName = $user->getProfile()?->fullName(); return new self( - id: $user->id(), - username: $user->username(), - email: $user->email(), + id: $user->id(), + username: $user->username(), + email: $user->email(), + fullName: $fullName ?? '', + avatarUrl: $user->getProfile()?->avatarUrl(), emailVerified: $user->isEmailVerified(), - roles: $roles !== null ? [$roles] : [], - joinedAt: $user->getMembership()?->joinedAt, - tenantId: $user->getMembership()?->tenantId, - createdAt: $user->createdAt()->format(\DateTimeInterface::RFC3339), + roles: $roles !== null ? [$roles] : [], + joinedAt: $user->getMembership()?->joinedAt, + tenantId: $user->getMembership()?->tenantId, + createdAt: $user->createdAt()->format(\DateTimeInterface::RFC3339), ); } @@ -42,14 +55,17 @@ public static function fromEntity(User $user): self public function toArray(): array { return [ - 'id' => $this->id, - 'username' => $this->username, - 'email' => $this->email, + 'id' => $this->id, + 'username' => $this->username, + 'email' => $this->email, + 'fullName' => $this->fullName, 'emailVerified' => $this->emailVerified, - 'createdAt' => $this->createdAt, - 'roles' => $this->roles, - 'joinedAt' => $this->joinedAt, - 'tenantId' => $this->tenantId, + 'createdAt' => $this->createdAt, + 'avatarUrl' => $this->avatarUrl, + 'roles' => $this->roles, + 'permissions' => $this->permissions, + 'joinedAt' => $this->joinedAt, + 'tenantId' => $this->tenantId, ]; } } diff --git a/plugins/User/Application/Services/TenantProfileProvisioner.php b/plugins/User/Application/Services/TenantProfileProvisioner.php index f308ee1..47095a8 100644 --- a/plugins/User/Application/Services/TenantProfileProvisioner.php +++ b/plugins/User/Application/Services/TenantProfileProvisioner.php @@ -4,47 +4,127 @@ namespace Plugins\User\Application\Services; +use Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract; +use Plugins\User\API\Contracts\TenantProfileReaderContract; use Plugins\User\Domain\Entities\UserProfile; use Plugins\User\Infrastructure\Persistence\UserSettingsRepository; /** - * Creates the initial per-tenant user_profiles row from an at-signup profile - * block. Application service: it consumes the repository ONLY — it never touches - * a DatabasePort. The repository (constructed against the resolved tenant - * connection) owns all SQL, keeping the access rule intact: + * The tenant user-profile service: WRITES the initial per-tenant user_profiles + * row from an at-signup profile block, and READS profile display data (the + * published TenantProfileReaderContract) for consumers like Tenancy's + * tenant-selection flow. * - * listener → THIS service → UserSettingsRepository → DatabasePort + * Application service: it consumes the repository ONLY — it never touches a + * DatabasePort. The repository owns all SQL, keeping the access rule intact: + * + * listener / MembershipService → THIS service → UserSettingsRepository → DatabasePort + * + * COMPOSITION: the tenant connection is only known per call (the tenantId + * arrives with the event or the selection), so — exactly like + * ProvisionTenantProfileListener — this service is the composition point: it + * resolves the tenant connection through Tenancy's PUBLISHED resolver contract + * and wires the repository against it. Two construction modes: + * + * - pinned: new TenantProfileProvisioner(profiles: $repo) (listener path, + * repository already built against the resolved tenant connection) + * - resolver: new TenantProfileProvisioner(connections: $resolver) (container + * binding — resolves the tenant DB per call from $tenantId) * * The write is a full upsert on user_id, so an outbox replay is idempotent. + * Reads are BEST-EFFORT and never throw (display data only). */ -final class TenantProfileProvisioner +final class TenantProfileProvisioner implements TenantProfileReaderContract { public function __construct( - private readonly UserSettingsRepository $profiles, - ) {} + private readonly ?UserSettingsRepository $profiles = null, + private readonly ?TenantConnectionResolverContract $connections = null, + ) { + } /** * @param array $profile whitelisted primitive fields * (first_name, last_name, phone, timezone, locale) from the event. + * @param string $tenantId tenant to write to — required in resolver mode, + * ignored when a pinned repository was injected. */ - public function provision(string $userId, array $profile): void + public function provision(string $userId, array $profile, string $tenantId = ''): void { if ($userId === '' || $profile === []) { return; } + $repository = $this->repositoryFor($tenantId); + if ($repository === null) { + return; + } + // Named constructor validates lengths/locale/timezone; omitted fields // fall back to the table's defaults inside the entity. $entity = UserProfile::fromInput( - userId: $userId, + userId: $userId, firstName: $profile['first_name'] ?? null, - lastName: $profile['last_name'] ?? null, + lastName: $profile['last_name'] ?? null, avatarUrl: null, - timezone: $profile['timezone'] ?? null, - locale: $profile['locale'] ?? null, - phone: $profile['phone'] ?? null, + timezone: $profile['timezone'] ?? null, + locale: $profile['locale'] ?? null, + phone: $profile['phone'] ?? null, ); - $this->profiles->saveProfile($entity); + $repository->saveProfile($entity); + } + + /** + * "First Last" from the tenant's user_profiles row. Best-effort: a missing + * profile, unreachable tenant DB, or unresolvable connection yields '' — + * display data never fails the calling flow (contract guarantee). + */ + public function fullName(string $userId, string $tenantId = ''): string + { + if ($userId === '') { + return ''; + } + + try { + $profile = $this->repositoryFor($tenantId)?->findProfile($userId); + } catch (\Throwable) { + return ''; + } + + if ($profile === null) { + return ''; + } + + return trim(trim((string) $profile->firstName()) . ' ' . trim((string) $profile->lastName())); + } + + public function getProfile(string $userId, string $tenantId = ''): ?UserProfile + { + if ($userId === '') { + return null; + } + try { + $profile = $this->repositoryFor($tenantId)?->findProfile($userId); + return $profile; + } catch (\Throwable) { + return null; + } + } + + /** + * The repository for this call: the pinned one when injected, else one + * composed against the tenant connection resolved from $tenantId. + */ + private function repositoryFor(string $tenantId): ?UserSettingsRepository + { + if ($this->profiles !== null) { + return $this->profiles; + } + + if ($this->connections === null || $tenantId === '') { + return null; + } + + return new UserSettingsRepository($this->connections->for($tenantId)); } } diff --git a/plugins/User/Application/Services/UserService.php b/plugins/User/Application/Services/UserService.php index cb8abd7..06f6ac6 100644 --- a/plugins/User/Application/Services/UserService.php +++ b/plugins/User/Application/Services/UserService.php @@ -73,6 +73,9 @@ public function __construct( private readonly ?BreachChecker $breachChecker = null, private readonly ?string $tenantId = null, private readonly ?MembershipServiceContract $membership = null, + // Tenant user_profiles read surface — attaches UserDTO.fullName when a + // membership pins the tenant. Best-effort (the reader never throws). + private readonly ?\Plugins\User\API\Contracts\TenantProfileReaderContract $profiles = null, ) { } @@ -273,9 +276,10 @@ private function provision(RegisterUserDTO $dto): array return [UserDTO::fromEntity($user), $plainToken]; } - public function find(string $id, bool $checkMembership = false): ?UserDTO + public function find(string $id, bool $checkMembership = false, bool $isAuth = false): ?UserDTO { - $this->requireSelfOrPermission($id, 'user:read-any'); + if (!$isAuth) + $this->requireSelfOrPermission($id, 'user:read-any'); $user = $this->repository->find($id); if ($checkMembership) { @@ -290,7 +294,17 @@ public function find(string $id, bool $checkMembership = false): ?UserDTO $user?->setMembership($membership); } - return $user === null ? null : UserDTO::fromEntity($user); + if ($user === null) { + return null; + } + + if ($this->profiles !== null) { + $user->setProfile($this->profiles->getProfile($user->id(), $this->tenantId)); + } + + $dto = UserDTO::fromEntity($user); + + return $dto; } public function update(string $id, UpdateUserDTO $dto): ?UserDTO @@ -408,6 +422,10 @@ public function verifyCredentials(string $identifier, string $password): ?UserDT $user?->setMembership($membership); + if ($this->profiles !== null) { + $user?->setProfile($this->profiles->getProfile($user?->id(), $this->tenantId)); + } + // 2. Timing-safe: run a hash comparison even when the user is unknown. $hash = $user?->passwordHash() ?? self::DECOY_HASH; $ok = $this->hasher->check($password, $hash); @@ -461,6 +479,9 @@ public function findByIdentifier(string $identifier, bool $checkMembership = fal $user?->setMembership($membership); } + if ($this->profiles !== null) { + $user?->setProfile($this->profiles->getProfile($user?->id(), $this->tenantId)); + } return $user === null ? null : UserDTO::fromEntity($user); } @@ -513,6 +534,10 @@ public function findByRememberToken(string $token): ?UserDTO return null; } + if ($this->profiles !== null) { + $user->setProfile($this->profiles->getProfile($user->id(), $this->tenantId)); + } + return UserDTO::fromEntity($user); } @@ -551,6 +576,9 @@ public function delete(string $id, bool $checkMembership = false): bool $user?->setMembership($membership); } + if ($this->profiles !== null) { + $user?->setProfile($this->profiles->getProfile($user?->id(), $this->tenantId)); + } $this->collector->beginCollection(); $this->transaction->begin(); diff --git a/plugins/User/Domain/Entities/User.php b/plugins/User/Domain/Entities/User.php index dcb8ba7..885c05a 100644 --- a/plugins/User/Domain/Entities/User.php +++ b/plugins/User/Domain/Entities/User.php @@ -44,7 +44,8 @@ final class User extends Entity protected array $hidden = ['password_hash', 'remember_token', 'email_verification_token_hash']; - protected TenantSummary|null $membership = null; + protected ?TenantSummary $membership = null; + protected ?UserProfile $profile = null; /** @@ -66,6 +67,25 @@ public function getMembership(): ?TenantSummary return $this->membership; } + /** + * Summary of setProfile + * @param mixed $profile + * @return void + */ + public function setProfile(?UserProfile $profile): void + { + $this->profile = $profile; + } + + /** + * Summary of getProfile + * @return UserProfile|null + */ + public function getProfile(): ?UserProfile + { + return $this->profile; + } + /** * Register a brand-new user. $passwordHash MUST already be a bcrypt hash * produced by the HashingPort — never a plaintext password. diff --git a/plugins/User/Domain/Entities/UserProfile.php b/plugins/User/Domain/Entities/UserProfile.php index c0f7a4a..de2ea60 100644 --- a/plugins/User/Domain/Entities/UserProfile.php +++ b/plugins/User/Domain/Entities/UserProfile.php @@ -116,6 +116,12 @@ public function timezone(): string { return $this->getString('timezone'); } public function locale(): string { return $this->getString('locale'); } public function phone(): string { return $this->getString('phone'); } + public function fullName(): string + { + $first = trim((string) $this->firstName()); + $last = trim((string) $this->lastName()); + return trim("{$first} {$last}"); + } private function nullable(string $key): ?string { $v = $this->getRawAttribute($key); diff --git a/plugins/User/Provider.php b/plugins/User/Provider.php index 8d6361f..0fcf8d9 100644 --- a/plugins/User/Provider.php +++ b/plugins/User/Provider.php @@ -23,7 +23,9 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; +use Plugins\User\API\Contracts\TenantProfileReaderContract; use Plugins\User\API\Contracts\UserServiceContract; +use Plugins\User\Application\Services\TenantProfileProvisioner; use Plugins\User\Application\Services\OutboxRelayService; use Plugins\User\Application\Services\UserService; use Plugins\User\Application\Services\UserSettingsService; @@ -74,11 +76,13 @@ public function requires(): array /** @return list */ public function exposes(): array { - // Only UserServiceContract is consumed cross-module (Auth/Tenancy). - // Settings are internal to this plugin (their own controller), so they - // are NOT published — the controller depends on the concrete service. + // UserServiceContract is consumed cross-module (Auth/Tenancy); + // TenantProfileReaderContract lets Tenancy read the tenant user_profiles + // display data (full name) at selection without raw SQL. Settings stay + // internal to this plugin (their own controller) and are NOT published. return [ UserServiceContract::class, + TenantProfileReaderContract::class, ]; } @@ -112,10 +116,19 @@ public function register(ModuleContainer $container): void ); }); - $container->bind(UserServiceContract::class, static fn(ModuleContainer $c) => - + // Published tenant-profile read surface (Identity/UserDTO fullName). + // Resolver mode: the tenant connection is resolved per call from the + // tenantId, through Tenancy's published contract (optional — reads + // degrade to '' when Tenancy is absent). + $container->bind(TenantProfileReaderContract::class, static fn(ModuleContainer $c) => + new TenantProfileProvisioner( + connections: $c->make(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class) + ? $c->make(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class) + : null, + )); - new UserService( + $container->bind(UserServiceContract::class, static fn(ModuleContainer $c) => + new UserService( repository: $c->make(UserRepository::class), transaction: $c->make(TransactionManager::class), collector: $c->make(DomainEventCollector::class), @@ -128,6 +141,7 @@ public function register(ModuleContainer $container): void breachChecker: $c->make(BreachChecker::class), tenantId: $c->has('tenant.current') ? (string) $c->make('tenant.current') : null, membership: $c->has(MembershipServiceContract::class) ? $c->make(MembershipServiceContract::class) : null, + profiles: $c->make(TenantProfileReaderContract::class), )); // Public/admin JSON controller. Bound explicitly so the OPTIONAL MailPort diff --git a/plugins/User/README.md b/plugins/User/README.md index 79348dd..66384ef 100644 --- a/plugins/User/README.md +++ b/plugins/User/README.md @@ -535,3 +535,18 @@ the domain. *Part of the AlfacodeTeam PhpServicePlatform. See the root `CLAUDE.md` and `docs/ai-context/` for framework-wide architecture.* + +## Tenant profile reads — `TenantProfileReaderContract` (published) + +`TenantProfileProvisioner` implements the published +`TenantProfileReaderContract` — `fullName(userId, tenantId): string` — in two +construction modes: **pinned** (repository already built against the resolved +tenant connection; the listener path) or **resolver** (container binding; +resolves the tenant DB per call through Tenancy's +`TenantConnectionResolverContract`). Reads are best-effort and never throw: a +missing profile or unreachable tenant DB yields `''`. Consumers: Tenancy's +tenant-selection flow (the JWT `name` claim) and `UserService::find()` (attaches +`UserDTO.fullName` when a membership pins the tenant). `UserDTO` also carries +`avatarUrl` and `permissions`. `UserServiceContract::find()` accepts +`bool $isAuth = false` — issuance-time lookups by Auth skip the +self-or-permission check (the request Identity is still guest during login). diff --git a/projects/Http/Controllers/ApiController.php b/projects/Http/Controllers/ApiController.php index 3b7c203..c9de007 100644 --- a/projects/Http/Controllers/ApiController.php +++ b/projects/Http/Controllers/ApiController.php @@ -9,6 +9,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; use Project\Http\Controllers\Concerns\InteractsWithCsrf; +use Project\Http\Controllers\Concerns\InteractsWithSession; /** * Base controller for JSON / API endpoints. @@ -26,6 +27,7 @@ abstract class ApiController implements RequestAware { use InteractsWithCsrf; + use InteractsWithSession; /** 200 OK with a `data` envelope. */ protected function ok(mixed $data = null, int $status = 200): Response diff --git a/projects/Http/Controllers/ViewController.php b/projects/Http/Controllers/ViewController.php index a98b474..bb9d84b 100644 --- a/projects/Http/Controllers/ViewController.php +++ b/projects/Http/Controllers/ViewController.php @@ -8,6 +8,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; use Plugins\View\API\Contracts\ViewRendererContract; use Project\Http\Controllers\Concerns\InteractsWithCsrf; +use Project\Http\Controllers\Concerns\InteractsWithSession; /** * Base controller for HTML / view endpoints. @@ -33,6 +34,7 @@ abstract class ViewController implements RequestAware { use InteractsWithCsrf; + use InteractsWithSession; protected const API_BASE = null; public function __construct( protected readonly ViewRendererContract $renderer, diff --git a/src/Kernel/Security/Identity.php b/src/Kernel/Security/Identity.php index 04e65e3..d87b728 100644 --- a/src/Kernel/Security/Identity.php +++ b/src/Kernel/Security/Identity.php @@ -11,6 +11,14 @@ public function __construct( public readonly array $roles, public readonly array $permissions, public readonly string $tokenType, + public readonly string $username = '', + public readonly string $email = '', + // First + last name from the TENANT user_profiles table — only present + // when the credential was minted with tenant context (empty otherwise). + public readonly string $fullName = '', + // Avatar URL from the TENANT user_profiles table — only present when the + // credential was minted with tenant context (empty otherwise). + public readonly ?string $avatarUrl = null, ) {} public function hasRole(string $role): bool @@ -36,8 +44,12 @@ public static function asUser( array $roles = ['user'], array $permissions = [], string $tokenType = 'jwt', + string $username = '', + string $email = '', + string $fullName = '', + ?string $avatarUrl = null, ): self { - return new self($userId, $tenantId, $roles, $permissions, $tokenType); + return new self($userId, $tenantId, $roles, $permissions, $tokenType, $username, $email, $fullName, $avatarUrl); } /** @param list $permissions */ diff --git a/tests/Unit/Plugins/Auth/Support/FakeAuthService.php b/tests/Unit/Plugins/Auth/Support/FakeAuthService.php index 843ab89..33afe23 100644 --- a/tests/Unit/Plugins/Auth/Support/FakeAuthService.php +++ b/tests/Unit/Plugins/Auth/Support/FakeAuthService.php @@ -40,7 +40,7 @@ public function createPersonalAccessToken(string $userId, string $name = 'defaul public function revokePersonalAccessToken(string $id): void {} public function issueJwt(string $userId, array $claims = [], int $ttlSeconds = 3600): string { return 'jwt'; } - public function startSession(SessionPort $session, string $userId, array $roles = [], array $permissions = [], string $tenantId = ''): void {} + public function startSession(SessionPort $session, string $userId, array $roles = [], array $permissions = [], string $tenantId = '', string $username = '', string $email = '', string $fullName = '', ?string $avatarUrl = null): void {} public function endSession(SessionPort $session): void {} public function revokeJwt(string $jti, int $ttlSeconds = 3600): void {} public function hashPassword(string $plain): string { return 'hash'; } diff --git a/tests/Unit/Plugins/Auth/Support/FakeUserService.php b/tests/Unit/Plugins/Auth/Support/FakeUserService.php index d39d736..9babbb0 100644 --- a/tests/Unit/Plugins/Auth/Support/FakeUserService.php +++ b/tests/Unit/Plugins/Auth/Support/FakeUserService.php @@ -39,7 +39,7 @@ public function seed(string $id, string $username, string $email): UserDTO /** @var list ids treated as having NO seat when membership is checked */ public array $nonMembers = []; - public function find(string $id, bool $checkMembership = false): ?UserDTO + public function find(string $id, bool $checkMembership = false, bool $isAuth = false): ?UserDTO { $this->findCalls[] = [$id, $checkMembership]; if ($checkMembership && \in_array($id, $this->nonMembers, true)) { diff --git a/tests/Unit/Plugins/Session/PreviousPageRecordingTest.php b/tests/Unit/Plugins/Session/PreviousPageRecordingTest.php new file mode 100644 index 0000000..a4be231 --- /dev/null +++ b/tests/Unit/Plugins/Session/PreviousPageRecordingTest.php @@ -0,0 +1,99 @@ +session = new FakeSession(); + } + + private function request(string $path, string $method = 'GET', array $query = []): Request + { + $container = new ModuleContainer(new CoreContainer()); + $container->setScope('session.management'); + $container->instance(SessionPort::class, $this->session); + + return Request::build(method: $method, path: $path, query: $query) + ->withContainer($container); + } + + private function recorded(): mixed + { + return $this->session->get(StartSessionStage::PREVIOUS_URL); + } + + public function test_records_successful_html_page_view_with_query(): void + { + (new StartSessionStage())->handle( + $this->request('/products', query: ['page' => '2']), + static fn (): Response => Response::html('

Products

'), + ); + + $this->assertSame('/products?page=2', $this->recorded()); + } + + public function test_records_pageflow_page_object(): void + { + (new StartSessionStage())->handle( + $this->request('/dashboard'), + static fn (): Response => Response::json(['component' => 'Dashboard']) + ->withHeader('X-Pageflow', 'true'), + ); + + $this->assertSame('/dashboard', $this->recorded()); + } + + public function test_skips_auth_registration_api_and_asset_paths(): void + { + $stage = new StartSessionStage(); + $html = static fn (): Response => Response::html('

ok

'); + + foreach (['/auth/login', '/login', '/register', '/oauth/authorize', '/password/reset', + '/api/users', '/ajx/users', '/verify-email', '/app.css'] as $path) { + $stage->handle($this->request($path), $html); + } + + $this->assertNull($this->recorded()); + } + + public function test_skips_non_get_plain_json_and_error_responses(): void + { + $stage = new StartSessionStage(); + + $stage->handle($this->request('/orders', method: 'POST'), static fn (): Response => Response::html('ok')); + $stage->handle($this->request('/orders'), static fn (): Response => Response::json(['data' => []])); + $stage->handle($this->request('/orders'), static fn (): Response => Response::notFound()); + + $this->assertNull($this->recorded()); + } + + public function test_last_page_wins(): void + { + $stage = new StartSessionStage(); + $html = static fn (): Response => Response::html('ok'); + + $stage->handle($this->request('/first'), $html); + $stage->handle($this->request('/second'), $html); + + $this->assertSame('/second', $this->recorded()); + } +} diff --git a/tests/Unit/Plugins/Tenancy/MembershipServiceTest.php b/tests/Unit/Plugins/Tenancy/MembershipServiceTest.php index f45705e..756c26b 100644 --- a/tests/Unit/Plugins/Tenancy/MembershipServiceTest.php +++ b/tests/Unit/Plugins/Tenancy/MembershipServiceTest.php @@ -5,7 +5,6 @@ namespace Tests\Unit\Plugins\Tenancy; use PHPUnit\Framework\TestCase; -use Plugins\Auth\API\Contracts\AuthServiceContract; use Plugins\Audit\API\Contracts\AuditServiceContract; use Plugins\Tenancy\Application\Ports\MembershipReader; use Plugins\Tenancy\Application\Services\MembershipService; @@ -55,28 +54,6 @@ public function find(string $userId, string $tenantId): ?Membership } }; - $auth = new class implements AuthServiceContract { - public function issueJwt(string $userId, array $claims = [], int $ttlSeconds = 3600): string - { - return 'jwt:' . $userId . ':' . ($claims['tnt'] ?? '') . ':' . implode(',', $claims['roles'] ?? []); - } - public function createPersonalAccessToken(string $userId, string $name = 'default', array $abilities = [], ?int $ttlSeconds = null): array - { - return ['id' => 'id', 'token' => 'tok']; - } - public function revokePersonalAccessToken(string $id): void {} - public function guard(\AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request $request): \Plugins\Auth\API\Guard - { - return \Plugins\Auth\API\Guard::fromRequest($request); - } - public function tokensFor(string $userId): array { return []; } - public function startSession(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort $session, string $userId, array $roles = [], array $permissions = [], string $tenantId = ''): void {} - public function endSession(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort $session): void {} - public function revokeJwt(string $jti, int $ttlSeconds = 3600): void {} - public function hashPassword(string $plain): string { return $plain; } - public function verifyPassword(string $plain, string $hash): bool { return $plain === $hash; } - }; - $sink = new class($audit) implements AuditServiceContract { public function __construct(private \ArrayObject $log) {} public function record(string $action, ?string $userId = null, ?string $tenantId = null, array $meta = [], ?string $ip = null): void @@ -85,7 +62,7 @@ public function record(string $action, ?string $userId = null, ?string $tenantId } }; - return new MembershipService($reader, $auth, $sink, tokenTtl: 1800); + return new MembershipService($reader, $sink); } public function test_my_tenants_lists_only_active_routable_memberships(): void @@ -104,21 +81,20 @@ public function test_my_tenants_lists_only_active_routable_memberships(): void $this->assertSame('member', $list[0]->role); } - public function test_select_tenant_mints_scoped_token_and_audits(): void + public function test_select_tenant_returns_verified_seat_and_audits(): void { $audit = new \ArrayObject(); $svc = $this->service([$this->membership('u1', 't1', role: 'admin')], $audit); - $selection = $svc->selectTenant('u1', 't1', '203.0.113.5'); + $seat = $svc->selectTenant('u1', 't1', '203.0.113.5'); - $this->assertSame('t1', $selection->tenantId); - $this->assertSame('admin', $selection->role); - $this->assertSame(1800, $selection->expiresIn); - $this->assertSame('jwt:u1:t1:admin', $selection->token); + $this->assertSame('t1', $seat->tenantId); + $this->assertSame('admin', $seat->role); $this->assertCount(1, $audit); $this->assertSame('tenant.switch', $audit[0]['action']); $this->assertSame('t1', $audit[0]['tenant']); + $this->assertSame(['role' => 'admin'], $audit[0]['meta']); } public function test_select_tenant_rejects_non_member_and_audits_denial(): void From 479c17c50fa6bda0ed9f60284dc4b8fa78e6f297 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Tue, 14 Jul 2026 05:45:44 +0300 Subject: [PATCH 032/140] chore(release): v1.0.10 --- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf36b18..ed23ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.10] - 2026-07-14 + +### Added +- **Display identity on the kernel `Identity`** — new best-effort fields + `username`, `email`, `fullName`, `avatarUrl`. `AuthService` fills + username/email from the central user store at issuance when the caller + doesn't supply them; they ride as OIDC claims (`preferred_username`, + `email`, `name`) on JWTs — rebuilt statelessly by `JwtAuthLayer` — and as + session keys (`auth.username/email/name/avatar`) for session logins, + remember-me resurrection and `GET /auth/me`. `fullName` comes from the + TENANT `user_profiles` table, so only tenant-scoped credentials carry it. +- **Post-login "previous page" redirect.** The Session plugin's + `StartSessionStage` now records the last eligible page view (GET + 2xx, + HTML navigation or Pageflow page object; auth/OAuth/API/asset paths exempt — + extend with the new `SESSION_PREVIOUS_EXEMPT` env) under + `StartSessionStage::PREVIOUS_URL`. On successful `POST /auth/login` the + redirect target is: an explicit `redirectTo` on the request (query/body) → + the recorded page (pulled one-time) → `/`. Browser POSTs get a 302; AJAX + callers get `redirectTo` in the JSON payload. Every candidate passes an + open-redirect guard (relative paths only). SocialAuth's web callback honours + the same recorded page before `SOCIAL_AUTH_SUCCESS_REDIRECT`. +- **User: published `TenantProfileReaderContract`** — tenant `user_profiles` + display reads (`fullName(userId, tenantId)`), implemented by + `TenantProfileProvisioner` in pinned-repository or per-call resolver mode; + best-effort, never throws. `UserDTO` gains `fullName`, `avatarUrl` and + `permissions`; `UserProfile::fullName()` composes first + last. +- Base controllers (`ApiController`, `ViewController`) now compose + `InteractsWithSession`, as documented — `sessionGet/put/pull`, `flash`, + `csrfToken` and friends are available on every controller. + +### Changed +- **Tenant selection decomposed (tenancy ≠ authentication).** + `MembershipService` is control plane only: `selectTenant()` re-verifies the + seat, audits, and returns the verified `TenantSummary` — it no longer mints + tokens and lost its Auth dependency. `TenantController` is the composition + point: it mints the `tnt` token via `AuthServiceContract` (with `roles` and + the `name` claim via `TenantProfileReaderContract`) and builds the + `TenantSelection` response. Response shape is unchanged. +- **Tenant-scoped auth data now rides the per-request `DatabasePort`.** Auth's + personal-access-token + device-session repositories, Audit's `audit_log`, + OAuth2's server tables and SocialAuth's `social_identities` resolve the + request connection (tenant-rebound by `TenantContextStage`) instead of + pinning the central connection; their migrations moved to each plugin's + `database/tenant-template/`. User, Tenancy control plane and Auth refresh + tokens stay pinned to central. +- `UserServiceContract::find()` gains `bool $isAuth = false` — issuance-time + lookups by Auth skip the self-or-permission check (the request Identity is + still guest during login). + +### Fixed +- **Login hang (30s `max_execution_time`)** — a container resolution cycle + `AuthService → UserService → MembershipService → AuthService` recursed + forever. Fixed twice over: the Auth provider resolves the user store through + a lazy closure, and the selection refactor removes the cycle's closing edge. +- Remember-me resurrection fataled when the user had no tenant (nullable + `tenantId` passed to `startSession()`). +- `UserDTO` declared a readonly property with a default value (PHP fatal on + every load); `permissions` is now a promoted constructor parameter. + ## [1.0.9] - 2026-07-13 ### Added From 2e865a67d18acbbfb96f79251e2325a95c16b7d9 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 16 Jul 2026 03:37:39 +0300 Subject: [PATCH 033/140] feat(kernel,tenancy,pageflow,auth): project essentials, strict tenant routing, boot requires validation, display-identity auth prop, login redirect, MySQL UTC --- .gitignore | 1 + CHANGELOG.md | 70 ++++++ plugins/Audit/Provider.php | 2 +- plugins/Auth/Provider.php | 6 +- plugins/Authorization/Provider.php | 2 +- .../Drivers/MySQLConfiguration.php | 10 +- plugins/Feedback/Provider.php | 5 +- .../Services/AuthorizationRequest.php | 4 + plugins/OAuth2/Provider.php | 7 +- plugins/OAuth2/resources/views/consent.php | 2 + plugins/OAuth2/resources/views/device.php | 2 + plugins/Pageflow/Http/PageflowAuth.php | 11 +- plugins/Pageflow/Http/PageflowResponder.php | 22 ++ plugins/Pageflow/Provider.php | 2 +- plugins/Pageflow/README.md | 34 +++ plugins/Pageflow/resources/layouts/app.php | 45 +++- plugins/Pageflow/ui/react/App.tsx | 14 ++ plugins/Pageflow/ui/react/useAuth.tsx | 8 + .../Http/Stages/RequireAuthStage.php | 19 ++ plugins/Settings/Provider.php | 2 +- plugins/Settings/module.json | 2 +- plugins/SiteSEO/Provider.php | 2 +- plugins/SiteSEO/Schema.php | 5 +- plugins/SocialAuth/Provider.php | 7 +- .../Http/Controllers/TenantPageController.php | 29 ++- .../Identification/HostTenantIdentifier.php | 9 +- .../Http/Identification/TenantIdentifier.php | 12 +- .../Http/Stages/TenantContextStage.php | 79 ++++--- plugins/Tenancy/Provider.php | 10 +- plugins/Tenancy/README.md | 54 +++-- plugins/Tenancy/module.json | 40 +++- plugins/Tenancy/ui/README.md | 5 + .../User/Application/Services/UserService.php | 10 +- .../Http/Controllers/UserFlowController.php | 31 ++- .../Http/Controllers/UserPageController.php | 12 +- plugins/User/Provider.php | 37 +++- plugins/User/resources/views/layouts/app.php | 9 +- plugins/User/ui/README.md | 6 + projects/Bootstrap/EntryHelpers.php | 34 +++ .../Concerns/InteractsWithGraphSeo.php | 209 ++++++++++++++++++ .../Stages/CompileServiceManifestStage.php | 32 ++- src/Kernel/Contracts/ModuleContract.php | 12 +- src/Kernel/Kernel.php | 74 ++++++- src/Kernel/Loading/OnDemandLoader.php | 16 +- src/Kernel/Pipelines/Http/HttpPipeline.php | 35 ++- .../Pipelines/Http/Stages/LoadStage.php | 30 ++- templates/app/bootstrap/app.php | 24 +- templates/frontend/README.md | 2 +- templates/frontend/docs/HOW_IT_WORKS.md | 47 ++-- .../src/surfaces/admin/Pages/Dashboard.tsx | 3 +- .../src/surfaces/admin/Pages/Login.tsx | 3 +- .../src/surfaces/admin/Pages/Users/Index.tsx | 3 +- .../src/surfaces/project/Pages/About.tsx | 3 +- templates/proj.json | 1 + 54 files changed, 977 insertions(+), 178 deletions(-) diff --git a/.gitignore b/.gitignore index 2af2ff8..33c5751 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ php_errors.log /dist/ /dist-toolchain/ *.phar +*.pdf # ── RUNTIME STATE (per-project var/ is ephemeral, regenerable) ─ # Root-level var/ (base fallback) + per-project projects/*/var/. diff --git a/CHANGELOG.md b/CHANGELOG.md index ed23ae8..65f2626 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **proj.json `"essentials"` — project-declared global modules.** + `Kernel::withEssentialModules()` now accepts module DOMAINS as well as + provider class-strings; domains resolve to providers at `build()` and an + unknown domain fails the boot. `EntryHelpers::projectEssentials()` reads the + new key and the scaffold bootstrap appends it — which plugins are global is + now a per-project deployment decision, not a code edit. Session-cookie apps + declare `auth.identity` + `user.management` here so `SessionAuthStage` + resolves logins on every page. +- **Boot-time `requires[]` validation.** `CompileServiceManifestStage` now + FAILS the boot on any module.json `requires` entry that matches no registered + module's `solves` (previously dropped silently — a typo or a plugin missing + from `withModules` surfaced only as an unbound-contract error at request + time). Port/contract class names no longer belong in `requires[]`. +- **let-migrate tenant resolver support classes** (ported to scaffolded + projects): `DatabaseTenantResolver` + `CentralTenantRegistry` + `Dsn` read + the tenant fleet from the central `tenants` table — `tenant:status` / + `tenant:migrate` and request routing share ONE registry. +- **Display identity in the `pageflow_auth` prop.** `PageflowAuth::resolve()` + now shares the non-sensitive display fields off the `Identity` — `username`, + `fullName`, `email`, `avatarUrl` — so the browser `useAuth()` renders the + real name/email/avatar instead of the raw user id. The `PageflowAuth` TS + type + guest default gain the fields. + +### Changed +- **STRICT tenant routing — no unscoped passthrough (BREAKING).** + `TenantContextStage` now 404s any request that resolves no tenant (cookie + hint first — principal-bound, guests included — then the `TENANCY_MODE` + identifier). Every served host must be assigned to a tenant + (`tenant:host:add`); control-plane code pins the central connection + explicitly. The remembered-tenant cookie's user binding is now actually + enforced (no cross-user replay). +- **Essential modules load their transitive `requires[]`.** Essential domains + are seeded into every request's dependency graph (previously an essential + registered alone and its dependencies were silently missing). Each module + still registers exactly once per request. +- **Tenancy module `requires` trimmed to `["database.management"]`** — the + always-on stage path. Its selection/admin/invitation/host routes now carry + `auth.identity` / `user.management` / `audit.trail` as route-level + `requires[]`, cutting the every-request graph from 13 modules (~135µs) to 2 + (~15µs) in a Tenancy-essential project. +- **One `Provider::requires()` convention.** All plugin providers now mirror + module.json domains (the single source of truth the kernel reads); the + `ModuleContract` docblock documents the convention. +- **Per-worker loading caches**: `LoadStage` memoizes resolved dependency + graphs; `OnDemandLoader` caches provider instances (providers are stateless + by contract). +- **Auth-required browser navigations redirect to login.** `RequireAuthStage` + now sends a full page load OR a Pageflow SPA navigation (detected via the + `X-Pageflow` header) to `/login?redirectTo=…` instead of a raw JSON 401; + genuine API/fetch callers (JSON expected, no `X-Pageflow`) still get the + machine-readable 401. The original path rides along as `redirectTo`. +- **MySQL sessions pinned to UTC.** `MySQLConfiguration` sets + `time_zone = '+00:00'` (via `MYSQL_ATTR_INIT_COMMAND` + `initStatements`, so + it survives auto-reconnect) — `NOW()` / `CURRENT_TIMESTAMP` and `TIMESTAMP` + read-back are now unambiguously UTC, matching the PHP-side UTC clock. + +### Fixed +- **Settings plugin required the non-existent `database.query` domain** (now + `database.management`) — the Database module was silently absent from its + graph; caught by the new boot-time validation. +- Scaffold template comments taught wrong `solves` values + (`database.query`, `crypto`, `i18n`, `commands`). + +### Security +- **SiteSEO JSON-LD stored XSS.** `Schema.php` now encodes structured data with + `JSON_HEX_TAG` so a `` in user-controlled content can't break out of + the ``; it must never ride + `window.initialPage` / `data-page`). +- **XHR navigation** (`X-Pageflow`) → the helpers skip ALL the OG/graph work and + return just the plain suffixed tab title (`"Product X · Site"`). The React + `App` syncs `document.title` from it on every navigation — pages do NOT need + `` for titles; the server is the single source of truth. Values + containing markup are ignored client-side (plain text only). +- Crawlers only ever take the full-load path, so SEO is complete without SSR. + +`` remains available for anything else a page wants to inject into the +head (extra meta, links) — just don't use it for the title on pages that pass +`seoHead`. + ## Security invariants (do not regress) - **Push signals, pull data** — the reactive channel emits prop key *names* only; diff --git a/plugins/Pageflow/resources/layouts/app.php b/plugins/Pageflow/resources/layouts/app.php index 9b28ac8..4def753 100644 --- a/plugins/Pageflow/resources/layouts/app.php +++ b/plugins/Pageflow/resources/layouts/app.php @@ -50,6 +50,32 @@ $pageTitle = htmlspecialchars((string) ($props['title'] ?? $appName), ENT_QUOTES, 'UTF-8'); $locale = htmlspecialchars((string) ($props['locale'] ?? 'en'), ENT_QUOTES, 'UTF-8'); +/* + * ── RICH SEO HEAD — the reserved `seoHead` prop ──────────────────────────── + * A controller builds the COMPLETE SEO block (, meta description, + * canonical, robots, hreflang, Open Graph + Twitter card, Schema.org JSON-LD + * @graph) with ONE call — InteractsWithGraphSeo::seoFor(title:, description:, + * path:, image:, type:, data:) — and passes the rendered string as the + * `seoHead` page prop. When present it OWNS the <title> (the default one below + * is skipped). It is pre-rendered, host-aware, escaped HTML from the Project + * layer — echo it RAW; never re-escape it. + * + * Crawlers always take the full-page-load path, so this is all they need. The + * prop is STRIPPED from the page object this shell boots the client with (see + * $bootPage below): the block contains a literal </script> (its JSON-LD tag), + * which would terminate the inline window.initialPage script early — and the + * client has no use for server-rendered head HTML anyway. + */ +$seoHead = (string) ($props['seoHead'] ?? ''); +$bootPage = $seoHead === '' ? $FLOW_PAGE : new \Plugins\Pageflow\Http\PageflowPage( + component: $FLOW_PAGE->component, + props: array_diff_key($props, ['seoHead' => true]), + url: $FLOW_PAGE->url, + version: $FLOW_PAGE->version, + clearHistory: $FLOW_PAGE->clearHistory, + encryptHistory: $FLOW_PAGE->encryptHistory, +); + // The `hkm ui` surface to boot + its Vite entry point (manifest key, relative to // the frontend/ vite root). Surface: render()'s $FLOW_SURFACE → `surface` shared // prop → VITE_SURFACE env → 'admin'. @@ -72,7 +98,16 @@ <meta name="csrf-token" content="<?= htmlspecialchars($FLOW_CSRF, ENT_QUOTES, 'UTF-8') ?>"> <?php endif; ?> - <title><?= $pageTitle ?> + + . */ ?> + + + + <?= htmlspecialchars($seoHead, ENT_QUOTES, 'UTF-8') ?> + + <?= $pageTitle ?> + @@ -91,8 +126,9 @@ * Publishes the page object as the `window.initialPage` global that OLD * Pageflow bundles boot from. The CURRENT client ignores this global and * boots from the root element's data-page attribute instead (see the body). + * $bootPage is $FLOW_PAGE minus the server-only `seoHead` prop. */ - echo $FLOW_PAGE->renderScript(); + echo $bootPage->renderScript(); ?> @@ -104,8 +140,9 @@ * * To switch to the CURRENT (Inertia v2) client, drop the legacy '; + // JSON_HEX_TAG: with slashes unescaped, a "" inside any + // user-sourced string value would otherwise close the JSON-LD block + // and execute markup (stored XSS on public pages). + return ''; } } \ No newline at end of file diff --git a/plugins/SocialAuth/Provider.php b/plugins/SocialAuth/Provider.php index 83782d4..03ffdbe 100644 --- a/plugins/SocialAuth/Provider.php +++ b/plugins/SocialAuth/Provider.php @@ -45,12 +45,7 @@ public function requires(): array // profiles onto central users and issues platform credentials via the // Auth plugin's published contracts; token sign-in verifies against the // provider over HttpClientPort. - return [ - DatabaseConnectionManagerContract::class, - UserServiceContract::class, - AuthServiceContract::class, - HttpClientPort::class, - ]; + return ['database.management', 'user.management', 'auth.identity', 'http.client']; } /** @return list */ diff --git a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantPageController.php b/plugins/Tenancy/Infrastructure/Http/Controllers/TenantPageController.php index 85d9d90..58bf032 100644 --- a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantPageController.php +++ b/plugins/Tenancy/Infrastructure/Http/Controllers/TenantPageController.php @@ -7,6 +7,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; use Plugins\Pageflow\Http\PageflowResponder; +use Project\Http\Controllers\Concerns\InteractsWithGraphSeo; /** * Pageflow (SPA) controller for the Tenancy plugin — the server half of the pages @@ -24,9 +25,16 @@ * Routes declare `requires: ["http.pageflow"]` so the responder resolves for the * request; Tenancy itself is an essential module, so its services are always * registered. + * + * SEO — every page here is private control-plane surface, so each passes the + * reserved `seoHead` prop with seoPrivate(): a correct plus + * noindex,nofollow (admin consoles must never enter a search index), with no + * OG/graph cost. The helper no-ops ('') on Pageflow XHR navigations. */ final class TenantPageController { + use InteractsWithGraphSeo; + public function __construct( private readonly PageflowResponder $pageflow, ) {} @@ -34,30 +42,41 @@ public function __construct( /** GET /tenants — the tenant picker for the authenticated user. */ public function index(Request $request): Response { - return $this->pageflow->render($request, 'Tenant/Index', 'admin'); + return $this->pageflow->render($request, 'Tenant/Index', 'admin', [ + 'seoHead' => $this->seoPrivate('Choose a workspace', request: $request), + ]); } /** GET /tenants/manage — the platform-admin tenant fleet (CRUD). */ public function manage(Request $request): Response { - return $this->pageflow->render($request, 'Tenant/Manage', 'admin'); + return $this->pageflow->render($request, 'Tenant/Manage', 'admin', [ + 'seoHead' => $this->seoPrivate('Tenant fleet', request: $request), + ]); } /** GET /tenants/create — the new-tenant provisioning form. */ public function create(Request $request): Response { - return $this->pageflow->render($request, 'Tenant/Create', 'admin'); + return $this->pageflow->render($request, 'Tenant/Create', 'admin', [ + 'seoHead' => $this->seoPrivate('New tenant', request: $request), + ]); } /** GET /tenants/{tenantId}/edit — edit a tenant's metadata. */ public function edit(Request $request, string $tenantId): Response { - return $this->pageflow->render($request, 'Tenant/Edit', 'admin', ['tenantId' => $tenantId]); + return $this->pageflow->render($request, 'Tenant/Edit', 'admin', [ + 'tenantId' => $tenantId, + 'seoHead' => $this->seoPrivate('Edit tenant', request: $request), + ]); } /** GET /tenant/hosts — manage the current tenant's custom domains. */ public function hosts(Request $request): Response { - return $this->pageflow->render($request, 'Tenant/Hosts', 'admin'); + return $this->pageflow->render($request, 'Tenant/Hosts', 'admin', [ + 'seoHead' => $this->seoPrivate('Custom domains', request: $request), + ]); } } diff --git a/plugins/Tenancy/Infrastructure/Http/Identification/HostTenantIdentifier.php b/plugins/Tenancy/Infrastructure/Http/Identification/HostTenantIdentifier.php index 69e0123..3d9f18e 100644 --- a/plugins/Tenancy/Infrastructure/Http/Identification/HostTenantIdentifier.php +++ b/plugins/Tenancy/Infrastructure/Http/Identification/HostTenantIdentifier.php @@ -13,16 +13,15 @@ * * acme.example.com -> tenant that registered & verified 'acme.example.com' * shop.acme.io -> tenant that registered & verified 'shop.acme.io' - * unknown.host -> '' (no verified host -> central; downstream 404s - * only if the route requires a tenant) + * unknown.host -> '' (no verified host — TenantContextStage answers + * 404: every host must be assigned to a tenant) * * Unlike {@see DomainTenantIdentifier}, this does NOT derive the id from the * host string — it maps the FULL hostname to a tenant_id through a verified row, * so tenants can bring their own apex/sub domains (not just labels under one * configured base domain). No auth is involved: it works for anonymous traffic. * - * An unknown or unverified host yields '' — the request keeps the central - * DatabasePort. The registry already restricts matches to status = verified. + * The registry already restricts matches to status = verified. */ final class HostTenantIdentifier implements TenantIdentifier { @@ -32,7 +31,7 @@ public function __construct( public function identify(Request $request): string { - $host = $this->normaliseHost($request->host()); + $host = $this->normaliseHost($request->host()); if ($host === '') { return ''; } diff --git a/plugins/Tenancy/Infrastructure/Http/Identification/TenantIdentifier.php b/plugins/Tenancy/Infrastructure/Http/Identification/TenantIdentifier.php index 1c1def4..087ca5c 100644 --- a/plugins/Tenancy/Infrastructure/Http/Identification/TenantIdentifier.php +++ b/plugins/Tenancy/Infrastructure/Http/Identification/TenantIdentifier.php @@ -17,10 +17,18 @@ * {@see \Plugins\Tenancy\Infrastructure\Http\Stages\TenantContextStage} + * {@see \Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract}. * - * Returning '' means "no tenant" — the request keeps the central DatabasePort. + * Returning '' means "no tenant identified" — TenantContextStage fails closed + * (404): every host must be assigned to a tenant, there is no unscoped + * passthrough to the central DatabasePort. An identifier MAY also throw + * UnknownTenantException to refuse a host explicitly — same 404 outcome. */ interface TenantIdentifier { - /** Tenant id for this request, or '' for an unscoped/central request. */ + /** + * Tenant id for this request, or '' when none could be identified. + * + * @throws \Plugins\Tenancy\Domain\Exceptions\UnknownTenantException + * to refuse the host explicitly (fail closed) + */ public function identify(Request $request): string; } diff --git a/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php b/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php index a67b1a9..f2b5fda 100644 --- a/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php +++ b/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php @@ -27,11 +27,11 @@ * container so every repository resolved downstream (ExecuteStage) transparently * talks to the tenant database. * - * Routing decision: - * - Identifier returns '' -> no rebind. The request keeps the central - * DatabasePort (login, tenant picker, apex/public pages). In claim mode a - * tenant-scoped controller must require an Identity (the `auth` filter) so an - * unscoped request can never read tenant data. + * Routing decision — STRICT, no unscoped passthrough: + * - No tenant (cookie empty AND identifier returns '' or throws) -> 404. + * Every host must be assigned to a tenant; a request that cannot be scoped + * is never served the central DatabasePort. Control-plane code that needs + * central pins it explicitly (ConnectionManager default), not via this stage. * - Tenant present -> rebind, or fail closed with a clean status. There is no * silent fallback to another tenant or to central. * @@ -81,33 +81,38 @@ public function handle(Request $request, callable $next): Response $jar = $container->has(CookieJar::class) ? $container->make(CookieJar::class) : null; $userId = $request->identity()?->userId ?? ''; - // Identify (JWT `tnt` claim / Host). The identifier ALWAYS wins when it - // has a value — a Host or a fresh claim is authoritative, so tenant - // switching takes effect immediately. Only when it yields '' do we fall - // back to the encrypted, user-bound cookie, letting a returning user keep - // their last selection without re-running the picker. - $tenantId = $this->rememberedTenant($jar, $request); - - if($tenantId === '') { - $tenantId = $identifier->identify($request); - $fromCookie = false; - } else { - $fromCookie = true; + $fromCookie = false; + $tenantId = $this->rememberedTenant($jar, $request, $userId); + $fromCookie = $tenantId !== ''; + + // The remembered selection (encrypted, principal-bound cookie) is tried + // first; only when there is no valid hint does the identifier run + // (JWT `tnt` claim / Host, per TENANCY_MODE). An identifier may throw + // UnknownTenantException to fail closed on a host it refuses to serve. + try { + if ($tenantId === '') { + $tenantId = $identifier->identify($request); + } + } catch (UnknownTenantException) { + return Response::notFound('Tenant not found.'); } - // Unscoped request (apex/central host, reserved sub-domain, or a guest in - // claim mode): no tenant to route — keep the central DatabasePort bound and - // continue. Without this, resolver->for('') would throw UnknownTenant and - // every control-plane/public request (login, OAuth2, marketing) would 404. - // if ($tenantId === '') { - // return $next($request); - // } + // EVERY request must resolve to a tenant — there is NO unscoped + // passthrough to the central DatabasePort. A host that is not assigned + // to a tenant (and a claim-mode request without a tenant claim/cookie) + // fails closed with a 404 here, so an unknown website pointed at this + // server is never served anything. Control-plane repositories that need + // the central connection pin it explicitly via the ConnectionManager + // default — they do not depend on this stage skipping the rebind. + if ($tenantId === '') { + return Response::notFound('Tenant not found.'); + } $resolver = $this->resolver ?? $container->make(TenantConnectionResolverContract::class); - + try { $db = $resolver->for($tenantId); } catch (UnknownTenantException) { @@ -178,30 +183,40 @@ private static function isConnectivityFault(\Throwable $e): bool } } - return false; + return false; } /** * Read the remembered tenant from the encrypted cookie. Returns '' unless the - * cookie decrypts cleanly AND was minted for THIS user (user-bound, so a - * cookie issued for another account can never be replayed). A guest (empty - * userId) never has a remembered tenant. + * cookie decrypts cleanly AND was minted for THIS principal — a cookie issued + * for another account can never be replayed, while a guest-minted cookie + * ('u' = '') still works for guests so public pages keep their selection. */ - private function rememberedTenant(?CookieJar $jar, Request $request): string + private function rememberedTenant(?CookieJar $jar, Request $request, string $userId): string { if ($jar === null) { return ''; } $raw = $jar->read($request, self::COOKIE); // decrypted; null if absent/tampered - + if ($raw === null) { return ''; } $data = json_decode($raw, true); - return is_string($data['t'] ?? null) ? $data['t'] : ''; + // Enforce the principal binding: the hint is only honoured by whoever it + // was minted for. A guest-minted hint ('u' = '') keeps working for + // guests — public pages don't require login — while a hint minted for + // one user is never replayed onto another user (or onto a guest after + // logout). Log-in flips the principal, so a fresh hint is re-minted. + $mintedFor = is_string($data['u'] ?? null) ? $data['u'] : ''; + if (!is_string($data['t'] ?? null) || $mintedFor !== $userId) { + return ''; + } + + return $data['t']; } /** Queue the encrypted, user-bound tenant hint (flushed by QueuedCookiesStage). */ diff --git a/plugins/Tenancy/Provider.php b/plugins/Tenancy/Provider.php index acb305a..610ea43 100644 --- a/plugins/Tenancy/Provider.php +++ b/plugins/Tenancy/Provider.php @@ -80,7 +80,15 @@ public function solves(): string public function requires(): array { - return ['database.management', 'auth.identity', 'user.management', 'audit.trail']; + // Documentation only — the kernel reads module.json "requires" (the + // single source of truth); keep this in sync with it. Module-level + // requires cover ONLY the always-on TenantContextStage path (the + // connection resolver needs the Database plugin); everything the + // selection/admin/invitation/host ROUTES need (auth.identity, + // user.management, audit.trail, http.pageflow) is declared per route + // in module.json routes[].requires — so a Tenancy-essential project + // does not register those modules on every request. + return ['database.management']; } public function exposes(): array diff --git a/plugins/Tenancy/README.md b/plugins/Tenancy/README.md index e0d7a48..1c468ec 100644 --- a/plugins/Tenancy/README.md +++ b/plugins/Tenancy/README.md @@ -6,7 +6,7 @@ authenticated `Identity.tenantId` to an **isolated tenant database** and rebinds correct tenant DB. Built on top of `plugins/Database`'s `ConnectionManager`. - **solves:** `tenancy.routing` -- **requires:** `database.management`, `auth.identity` +- **requires:** `database.management` (stage path only — its routes carry `auth.identity`/`user.management`/`audit.trail` as route-level `requires[]`) - **exposes:** `TenantRegistryContract`, `TenantConnectionResolverContract`, `MembershipServiceContract`, `InvitationServiceContract` ## Two planes @@ -23,17 +23,18 @@ correct tenant DB. Built on top of `plugins/Database`'s `ConnectionManager`. hkm migrate:run # creates tenants, user_tenants (users lives in plugins/User) ``` -2. **Register as an ESSENTIAL module** so every request is routed. In - `projects/<name>/bootstrap/app.php`: - ```php - ->withEssentialModules([ - \Plugins\Database\Provider::class, // database.management (required dep) - \Plugins\Tenancy\Provider::class, // tenancy.routing - // ... Crypto (EncryptionPort), RedisCache (CachePort) must also be available - ]) +2. **Register as an ESSENTIAL module** so every request is routed — declared by + the PROJECT in `proj.json` (the bootstrap wires + `->withEssentialModules(EntryHelpers::projectEssentials($projectRoot))`): + ```jsonc + // proj.json + "essentials": ["tenancy.routing"] ``` - `EncryptionPort` (Crypto) and `CachePort` (RedisCache) are dependencies of the - resolver/registry — ensure both are wired. + The domain resolves to the provider at `build()` (unknown domain = boot + failure), and essentials load their transitive `requires[]` — so + `database.management` comes along automatically. `EncryptionPort` and + `CachePort` are core ports (bootstrap `withPorts`) used by the + resolver/registry — ensure both are bound. 3. **Mint a tenant-scoped Identity** in your Auth layer. After the user selects a tenant, re-check `user_tenants` and put the tenant in the JWT `tnt` claim; the @@ -110,8 +111,9 @@ identity store, never the request body): POST /ajx/invitations/accept { "token": "…" } → { "tenantId": "…" } ``` -This is why Tenancy `requires: ["user.management"]` — `InvitationController` -resolves the caller's verified email via `UserServiceContract`. +This is why the invitation route carries `"requires": ["user.management"]` in +`module.json` — `InvitationController` resolves the caller's verified email via +`UserServiceContract` (route-level, so it loads only when the endpoint is hit). ## Refresh tokens — moved to `Plugins\Auth` @@ -195,13 +197,37 @@ it fleet-migrates every active tenant by default. | Env | Default | Meaning | |---|---|---| -| `TENANCY_MODE` | `tenant` | `legacy` \| `dual-write` \| `tenant` (migration phases) | +| `TENANCY_MODE` | `claim` | tenant identification: `claim` (Identity.tenantId) \| `domain` (Host sub-domain label) \| `host` (full Host via `tenant_hosts`) | +| `TENANCY_BASE_DOMAINS` | — | domain mode: comma-separated base domains a tenant label hangs off | +| `TENANCY_RESERVED_SUBDOMAINS` | `www,api,admin,…` | domain mode: labels that are never tenants (map to central) | | `TENANCY_REGISTRY_TTL` | `60` | registry cache TTL (s) | | `TENANCY_BREAKER_THRESHOLD` | `5` | connectivity failures before the breaker opens | | `TENANCY_BREAKER_WINDOW` | `60` | sliding window (s) failures must occur within | | `TENANCY_BREAKER_COOLDOWN` | `30` | breaker open window (s) | | `TENANCY_TEMPLATE_PATH` | bundled | tenant template migrations path | +## Strict routing — every host is a tenant + +Routing is **strict**: every request must resolve to a tenant (remembered +cookie hint first — principal-bound — then the `TENANCY_MODE` identifier). A +request that cannot be scoped — an unknown host, or no tenant claim/cookie — +**fails closed with 404**; there is no unscoped passthrough to the central +connection. Register every served host (`tenant:host:add <host> --verified` in +host mode). Control-plane code that needs central pins it explicitly via the +`ConnectionManager` default. + +## Activation & per-request cost + +Tenancy must register on EVERY request — the project declares it in `proj.json`: +`"essentials": ["tenancy.routing"]` (resolved by `Kernel::withEssentialModules()`; +an unknown domain fails the boot). Module-level `requires` is just +`["database.management"]` — the always-on stage path — so the every-request +graph stays at two modules; the selection/admin/invitation/host routes pull +`auth.identity` / `user.management` / `audit.trail` via route-level +`requires[]` only when hit. A single-tenant project must leave Tenancy out of +`withModules` entirely (not merely out of essentials) — the always-on stage +fails loudly when the module never registered. + ## Swoole connection pooling (optional optimization) By default `ConnectionManager` is request-scoped, so tenant sockets aren't reused diff --git a/plugins/Tenancy/module.json b/plugins/Tenancy/module.json index 63b572f..4de224e 100644 --- a/plugins/Tenancy/module.json +++ b/plugins/Tenancy/module.json @@ -5,13 +5,7 @@ "type": "module", "description": "Multi-tenant control plane: tenant registry + per-tenant database routing. Identifies the tenant by TENANCY_MODE \u2014 'claim' (default: authenticated Identity.tenantId, the SaaS/JWT model) or 'domain' (the Host sub-domain, the anonymous storefront model) \u2014 then rebinds an isolated tenant DatabasePort into the request container so every repository talks to the correct tenant database. Database-per-tenant isolation (MySQL/PostgreSQL/SQLite) on top of plugins/Database ConnectionManager.", "requires": [ - "tenant.settings", - "database.management", - "auth.identity", - "user.management", - "view.rendering", - "validation.rules", - "audit.trail" + "database.management" ], "views": "resources/views", "exposes": [ @@ -110,6 +104,11 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantController@mine", "filters": [ "auth" + ], + "requires": [ + "auth.identity", + "user.management", + "audit.trail" ] }, { @@ -118,6 +117,11 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantController@select", "filters": [ "auth" + ], + "requires": [ + "auth.identity", + "user.management", + "audit.trail" ] }, { @@ -126,6 +130,10 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\InvitationController@accept", "filters": [ "auth" + ], + "requires": [ + "user.management", + "audit.trail" ] }, { @@ -134,6 +142,9 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@index", "filters": [ "auth" + ], + "requires": [ + "audit.trail" ] }, { @@ -142,6 +153,9 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@store", "filters": [ "auth" + ], + "requires": [ + "audit.trail" ] }, { @@ -150,6 +164,9 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@instructions", "filters": [ "auth" + ], + "requires": [ + "audit.trail" ] }, { @@ -158,6 +175,9 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@verify", "filters": [ "auth" + ], + "requires": [ + "audit.trail" ] }, { @@ -166,6 +186,9 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@makePrimary", "filters": [ "auth" + ], + "requires": [ + "audit.trail" ] }, { @@ -174,6 +197,9 @@ "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@destroy", "filters": [ "auth" + ], + "requires": [ + "audit.trail" ] } ], diff --git a/plugins/Tenancy/ui/README.md b/plugins/Tenancy/ui/README.md index f52e6bd..d76fa7f 100644 --- a/plugins/Tenancy/ui/README.md +++ b/plugins/Tenancy/ui/README.md @@ -46,6 +46,11 @@ plugins/Tenancy/ui/ `/tenants/manage|create|{id}/edit` render through the **admin** surface; the tenant-facing `/tenants` and `/tenant/hosts` through the **site** surface. +Every page here is private control-plane surface, so `TenantPageController` +passes the reserved `seoHead` prop built with `seoPrivate()` (branded title + +`noindex, nofollow`). Pages must NOT set `<Head title>` — the client syncs the +tab title from `seoHead` on every navigation (see `plugins/Pageflow/README.md`). + ## Data flow The page shells carry no data props (except `Tenant/Edit`, which gets diff --git a/plugins/User/Application/Services/UserService.php b/plugins/User/Application/Services/UserService.php index 06f6ac6..b55aaef 100644 --- a/plugins/User/Application/Services/UserService.php +++ b/plugins/User/Application/Services/UserService.php @@ -299,7 +299,7 @@ public function find(string $id, bool $checkMembership = false, bool $isAuth = f } if ($this->profiles !== null) { - $user->setProfile($this->profiles->getProfile($user->id(), $this->tenantId)); + $user->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); } $dto = UserDTO::fromEntity($user); @@ -423,7 +423,7 @@ public function verifyCredentials(string $identifier, string $password): ?UserDT $user?->setMembership($membership); if ($this->profiles !== null) { - $user?->setProfile($this->profiles->getProfile($user?->id(), $this->tenantId)); + $user?->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); } // 2. Timing-safe: run a hash comparison even when the user is unknown. @@ -480,7 +480,7 @@ public function findByIdentifier(string $identifier, bool $checkMembership = fal $user?->setMembership($membership); } if ($this->profiles !== null) { - $user?->setProfile($this->profiles->getProfile($user?->id(), $this->tenantId)); + $user?->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); } return $user === null ? null : UserDTO::fromEntity($user); @@ -535,7 +535,7 @@ public function findByRememberToken(string $token): ?UserDTO } if ($this->profiles !== null) { - $user->setProfile($this->profiles->getProfile($user->id(), $this->tenantId)); + $user->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); } return UserDTO::fromEntity($user); @@ -577,7 +577,7 @@ public function delete(string $id, bool $checkMembership = false): bool $user?->setMembership($membership); } if ($this->profiles !== null) { - $user?->setProfile($this->profiles->getProfile($user?->id(), $this->tenantId)); + $user?->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); } $this->collector->beginCollection(); diff --git a/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php b/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php index 832df35..04b2821 100644 --- a/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php +++ b/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php @@ -9,6 +9,7 @@ use Plugins\Pageflow\Http\PageflowResponder; use Plugins\User\API\Contracts\UserServiceContract; use Plugins\User\API\DTOs\ListUsersQuery; +use Project\Http\Controllers\Concerns\InteractsWithGraphSeo; /** * Pageflow (SPA) controller for the User plugin — the server half of the pages @@ -20,9 +21,18 @@ * * Routes declare `requires: ["http.pageflow","user.management"]` so both the * responder and this service resolve for the request. + * + * SEO — every page passes the reserved `seoHead` prop the stock Pageflow layout + * renders into the HTML shell. The public /register page gets the full rich + * head (canonical, robots, OG/Twitter, JSON-LD graph) via seoFor(); the + * auth-gated pages and the token-bearing /verify-email landing get seoPrivate() + * — a correct <title> plus noindex,nofollow, with no graph cost. Both helpers + * no-op ('') on Pageflow XHR navigations, so SPA hops pay nothing. */ final class UserFlowController { + use InteractsWithGraphSeo; + public function __construct( private readonly PageflowResponder $pageflow, private readonly UserServiceContract $users, @@ -38,6 +48,7 @@ public function adminIndex(Request $request): Response 'users' => array_map([$this, 'row'], $page->items), 'hasMore' => $page->hasMore, 'nextCursor' => $page->nextCursor(), + 'seoHead' => $this->seoPrivate('Users', request: $request), ]); } @@ -47,25 +58,36 @@ public function adminShow(Request $request, string $id): Response $user = $this->users->find($id); return $this->pageflow->render($request, 'User/Show', 'admin', [ - 'user' => $user !== null ? $this->row($user) : null, + 'user' => $user !== null ? $this->row($user) : null, + 'seoHead' => $this->seoPrivate($user !== null ? "User {$user->username}" : 'User', request: $request), ]); } /** Public: registration form → component "User/Register". */ public function register(Request $request): Response { - return $this->pageflow->render($request, 'User/Register', 'admin'); + return $this->pageflow->render($request, 'User/Register', 'admin', [ + 'seoHead' => $this->seoFor( + title: 'Create your account', + description: 'Sign up in seconds — create a free account and get instant access.', + path: '/register', + breadcrumbs: [['Home', '/'], ['Create your account', '/register']], + request: $request, + ), + ]); } /** * Public: email-verification landing → component "User/VerifyEmail". The * emailed link points here (`/verify-email?token=...`); the token is passed * as a prop so the page can prefill and POST it to /ajx/users/verify. + * noindex — a token-bearing URL must never enter a search index. */ public function verifyEmail(Request $request): Response { return $this->pageflow->render($request, 'User/VerifyEmail', 'admin', [ - 'token' => (string) $request->query('token', ''), + 'token' => (string) $request->query('token', ''), + 'seoHead' => $this->seoPrivate('Verify your email', request: $request), ]); } @@ -78,7 +100,8 @@ public function profile(Request $request): Response : null; return $this->pageflow->render($request, 'User/Profile', 'admin', [ - 'user' => $user !== null ? $this->row($user) : null, + 'user' => $user !== null ? $this->row($user) : null, + 'seoHead' => $this->seoPrivate('Your profile', request: $request), ]); } diff --git a/plugins/User/Infrastructure/Http/Controllers/UserPageController.php b/plugins/User/Infrastructure/Http/Controllers/UserPageController.php index c42d8ab..fa92e92 100644 --- a/plugins/User/Infrastructure/Http/Controllers/UserPageController.php +++ b/plugins/User/Infrastructure/Http/Controllers/UserPageController.php @@ -6,7 +6,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Http\{Request, Response}; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer; -use Project\Http\Controllers\Concerns\HasRequest; +use Project\Http\Controllers\Concerns\InteractsWithGraphSeo; use Project\Http\Controllers\ViewController; /** @@ -21,6 +21,8 @@ */ final class UserPageController extends ViewController { + use InteractsWithGraphSeo; + protected const API_BASE = '/ajx/users'; public function index(): Response @@ -68,6 +70,12 @@ private function page(string $view, array $data, string $apiBase = self::API_BAS { // The JSON endpoints live under /ajx/...; hand the base to the layout // so the AJAX UI calls the real routes instead of the view's default. - return $this->view($view, $data + ['apiBase' => $apiBase], 'user::layouts/app'); + // Every page here is a private app shell (admin CRUD, token landing, + // account settings), so the layout gets a seoPrivate() head: a branded + // <title> plus noindex,nofollow — these URLs must never be indexed. + return $this->view($view, $data + [ + 'apiBase' => $apiBase, + 'seoHead' => $this->seoPrivate((string) ($data['title'] ?? 'Users')), + ], 'user::layouts/app'); } } diff --git a/plugins/User/Provider.php b/plugins/User/Provider.php index 0fcf8d9..87b29e2 100644 --- a/plugins/User/Provider.php +++ b/plugins/User/Provider.php @@ -64,12 +64,15 @@ public function solves(): string public function requires(): array { return [ - DatabaseConnectionManagerContract::class, - AuditServiceContract::class, - HashingPort::class, - CachePort::class, - ViewRendererContract::class, - HttpClientPort::class, // breached-password screening (opt-in via USER_BREACH_CHECK) + 'database.management', + 'crypto.services', + 'cache.redis', + 'view.rendering', + 'http.client', // breached-password screening (opt-in via USER_BREACH_CHECK) + 'validation.rules', + 'mail.delivery', + 'feedback.management', + 'audit.trail', ]; } @@ -120,12 +123,22 @@ public function register(ModuleContainer $container): void // Resolver mode: the tenant connection is resolved per call from the // tenantId, through Tenancy's published contract (optional — reads // degrade to '' when Tenancy is absent). - $container->bind(TenantProfileReaderContract::class, static fn(ModuleContainer $c) => - new TenantProfileProvisioner( - connections: $c->make(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class) - ? $c->make(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class) - : null, - )); + // makeInScope: User cannot declare tenancy.routing in requires[] + // (Tenancy already requires user.management — a requires cycle would + // fail the boot), so a plain make() from the user.management scope + // throws. Resolve the PUBLIC contract under Tenancy's own scope, + // guarded — reads are best-effort by contract, so a request that never + // loaded Tenancy simply yields no profile data. + $container->bind(TenantProfileReaderContract::class, static function (ModuleContainer $c) { + $resolver = null; + try { + $resolver = $c->makeInScope(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class, 'tenancy.routing'); + } catch (\Throwable) { + // Tenancy absent for this request — profile reads degrade to ''. + } + + return new TenantProfileProvisioner(connections: $resolver); + }); $container->bind(UserServiceContract::class, static fn(ModuleContainer $c) => new UserService( diff --git a/plugins/User/resources/views/layouts/app.php b/plugins/User/resources/views/layouts/app.php index 546b831..6127153 100644 --- a/plugins/User/resources/views/layouts/app.php +++ b/plugins/User/resources/views/layouts/app.php @@ -7,10 +7,13 @@ * @var string $apiBase Base path of the JSON API (e.g. /ajx/users). * @var string $csrf CSRF token (HMAC, bound to the session cookie). * @var string $view Rendered child-view HTML (injected by the renderer). + * @var string $seoHead Optional pre-rendered SEO head block (seoPrivate()) — + * owns <title> + robots when present; echo RAW. */ $title = $title ?? 'Users'; $apiBase = $apiBase ?? '/ajx/users'; $csrf = $csrf ?? ''; +$seoHead = $seoHead ?? ''; ?> <!DOCTYPE html> <html lang="en"> @@ -18,7 +21,11 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="csrf-token" content="<?= htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8') ?>"> - <title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?> · User + + + + <?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?> · User + + + +
+
+
+
+ +
+

OAuth2 Admin

+

Tenant-wide administration — all clients, scopes and authorized grants.

+
+
+
+ signed in as
+ logout +
+
+ +
+ host + user + +
+ + + +
+ + + +
+ + +
+
+

Clients

Every OAuth client registered in this tenant.

+
+ + +
+
+
+ + + +
Nameclient_idOwnerTypeScopesStatus
Loading…
+
+
+ + + + + + +
+ + + + + + + diff --git a/plugins/OAuth2/ui/admin/Pages/OAuth2/Admin.tsx b/plugins/OAuth2/ui/admin/Pages/OAuth2/Admin.tsx new file mode 100644 index 0000000..7feda56 --- /dev/null +++ b/plugins/OAuth2/ui/admin/Pages/OAuth2/Admin.tsx @@ -0,0 +1,518 @@ +import { useCallback, useEffect, useState } from "react"; +import { useAuth, Head, Link } from "@pageflow/react"; +import { toast } from "sonner"; +import { Toaster } from "@ui/sonner"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ui/table"; +import { Badge } from "@ui/badge"; +import { Button } from "@ui/button"; +import { Input } from "@ui/input"; +import { Label } from "@ui/label"; +import { Switch } from "@ui/switch"; +import { Avatar, AvatarFallback, AvatarImage } from "@ui/avatar"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@ui/dialog"; +import { OAUTH_GRANT_TYPES, type AdminClientRow, type OwnerProfile } from "@oauth2"; + +// OAuth2 admin dashboard — a PLUGIN-contributed Pageflow page. The admin surface +// globs plugins/*/admin/Pages/**, so this resolves as component "OAuth2/Admin". +// Server: Plugins\OAuth2 AdminUiController@dashboard (session + admin gated). +// Data comes from the /oauth/admin/* JSON API, fetched same-origin (session cookie). + +type ScopeEntry = { id: string; description: string }; +type Grant = { id: string; client_id: string; user_id: string; scopes: string[]; expires_at: string }; +type Result = { ok: true; data: T } | { ok: false }; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +async function api(path: string, opts: RequestInit = {}): Promise { + const headers: Record = { Accept: "application/json" }; + if (opts.body) headers["Content-Type"] = "application/json"; + const res = await fetch(path, { credentials: "same-origin", headers, ...opts }); + const text = await res.text(); + let body: any = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + if (!res.ok && res.status !== 204) { + const msg = body?.error?.message || body?.error || body?.message || `HTTP ${res.status}`; + const err: any = new Error(msg); + err.status = res.status; + throw err; + } + return body; +} +const pick = (body: any, key: string): any[] => body?.data?.[key] ?? body?.[key] ?? []; + +// Clipboard that also works in an INSECURE context (plain http://host, where +// navigator.clipboard is undefined) via a legacy execCommand fallback. +function legacyCopy(text: string): boolean { + try { + const ta = document.createElement("textarea"); + ta.value = text; + ta.setAttribute("readonly", ""); + ta.style.position = "fixed"; + ta.style.top = "-1000px"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(ta); + return ok; + } catch { + return false; + } +} +function copyToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text).then( + () => true, + () => legacyCopy(text), + ); + } + return Promise.resolve(legacyCopy(text)); +} +function copyText(text: string) { + void copyToClipboard(text).then((ok) => + ok ? toast.success("Copied", { description: text }) : toast.error("Copy failed"), + ); +} + +function useAction() { + return useCallback(async (fn: () => Promise, ok?: string): Promise> => { + try { + const data = await fn(); + if (ok) toast.success(ok); + return { ok: true, data }; + } catch (e: any) { + toast.error(e?.message ?? String(e)); + return { ok: false }; + } + }, []); +} + +export default function OAuth2Admin() { + const auth = useAuth(); + const [host, setHost] = useState(""); + useEffect(() => setHost(typeof window !== "undefined" ? window.location.host : ""), []); + + return ( + <> + + +
+
+
+
🛡
+
+

OAuth2 Admin

+

+ Tenant-wide administration — all clients, scopes and authorized grants. +

+
+
+
+ signed in as {auth.fullName || auth.email || auth.userId} +
{host}
+
+
+ + + + Clients + Scopes + Grants + + + + + + + + + + + +
+ + ); +} + +function OwnerCell({ owner, fallbackId }: { owner?: OwnerProfile | null; fallbackId?: string | null }) { + if (!owner && !fallbackId) return ; + const name = owner?.full_name || owner?.username || fallbackId || "—"; + const initials = (owner?.full_name || owner?.username || owner?.email || fallbackId || "?").slice(0, 2).toUpperCase(); + return ( +
+ + {owner?.avatar_url ? : null} + {initials} + +
+
{name}
+ {owner?.email &&
{owner.email}
} +
+
+ ); +} + +function ClientsCard() { + const run = useAction(); + const [clients, setClients] = useState(null); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + const r = await run(() => api("/oauth/admin/clients").then((b) => pick(b, "clients") as AdminClientRow[])); + if (r.ok) setClients(r.data); + setLoading(false); + }, [run]); + useEffect(() => void load(), [load]); + + const rotate = async (id: string) => { + const r = await run(() => api(`/oauth/admin/clients/${encodeURIComponent(id)}/rotate`, { method: "POST" })); + if (r.ok) { + const secret = (r.data.data ?? r.data).client_secret; + await copyToClipboard(secret); + toast.success("Secret rotated — copied", { description: secret }); + } + }; + const revoke = async (id: string, name: string) => { + const r = await run(() => api(`/oauth/admin/clients/${encodeURIComponent(id)}`, { method: "DELETE" }), `Revoked “${name}”`); + if (r.ok) void load(); + }; + + return ( + + +
+ Clients + Every OAuth client registered in this tenant. +
+
+ + +
+
+ +
+ + + + Name + client_id + Owner + Type + Scopes + Status + Actions + + + + {clients?.length === 0 && ( + + + No clients yet. + + + )} + {clients?.map((c) => ( + + {c.name} + + + + + + + + {c.confidential ? "confidential" : "public"} + + + {(c.scopes ?? []).join(" ") || "—"} + + + {c.revoked ? revoked : active} + + +
+ {!c.revoked && (c.grant_types ?? []).includes("authorization_code") && ( + + )} + {c.confidential && ( + + )} + {!c.revoked && ( + + )} +
+
+
+ ))} +
+
+
+
+
+ ); +} + +function NewClientDialog({ onCreated }: { onCreated: () => void }) { + const run = useAction(); + const [open, setOpen] = useState(false); + const [name, setName] = useState("Admin-created client"); + const [redirect, setRedirect] = useState(""); + const [scopes, setScopes] = useState(""); + const [grants, setGrants] = useState(["authorization_code", "refresh_token"]); + const [isPublic, setIsPublic] = useState(true); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (typeof window !== "undefined") setRedirect(window.location.origin + "/oauth/callback"); + }, []); + + const toggle = (g: string) => setGrants((cur) => (cur.includes(g) ? cur.filter((x) => x !== g) : [...cur, g])); + + const submit = async () => { + setBusy(true); + const r = await run( + () => + api("/oauth/admin/clients", { + method: "POST", + body: JSON.stringify({ + name: name.trim(), + redirect_uris: redirect.trim() ? [redirect.trim()] : [], + scopes: scopes.trim() ? scopes.trim().split(/\s+/) : [], + grant_types: grants, + public: isPublic, + }), + }), + `Created “${name.trim()}”`, + ); + setBusy(false); + if (r.ok) { + const c = r.data.data ?? r.data; + if (c.client_secret) { + await copyToClipboard(c.client_secret); + toast.success("client_secret copied (shown once)", { description: c.client_secret }); + } + setOpen(false); + onCreated(); + } + }; + + return ( + + + + + + + Register OAuth client + Created in the current tenant. Confidential secrets are shown once. + +
+
+ + setName(e.target.value)} /> +
+
+ + setRedirect(e.target.value)} /> +
+
+ + setScopes(e.target.value)} /> +

Every scope must already exist in the catalogue.

+
+
+ +
+ {OAUTH_GRANT_TYPES.map((g) => ( + + ))} +
+
+
+
+ +

Off = confidential (issues a secret).

+
+ +
+
+ + + + + + +
+
+ ); +} + +function ScopesCard() { + const run = useAction(); + const [scopes, setScopes] = useState(null); + const [id, setId] = useState(""); + const [desc, setDesc] = useState(""); + + const load = useCallback(async () => { + const r = await run(() => api("/oauth/admin/scopes").then((b) => pick(b, "scopes") as ScopeEntry[])); + if (r.ok) setScopes(r.data); + }, [run]); + useEffect(() => void load(), [load]); + + const add = async () => { + const r = await run( + () => api("/oauth/admin/scopes", { method: "POST", body: JSON.stringify({ id: id.trim(), description: desc.trim() }) }), + `Added “${id.trim()}”`, + ); + if (r.ok) { + setId(""); + setDesc(""); + void load(); + } + }; + const del = async (sid: string) => { + const r = await run(() => api(`/oauth/admin/scopes/${encodeURIComponent(sid)}`, { method: "DELETE" }), `Deleted “${sid}”`); + if (r.ok) void load(); + }; + + return ( + + + Scope catalogue + Grantable scopes shown on consent and validated at /authorize. + + +
+
+ + setId(e.target.value)} placeholder="read" /> +
+
+ + setDesc(e.target.value)} placeholder="Read your data" /> +
+ +
+
+ {scopes?.length === 0 &&

No scopes registered.

} + {scopes?.map((s) => ( +
+
+ {s.id} + — {s.description || "no description"} +
+ +
+ ))} +
+
+
+ ); +} + +function GrantsCard() { + const run = useAction(); + const [tokens, setTokens] = useState(null); + + const load = useCallback(async () => { + const r = await run(() => api("/oauth/admin/authorized-tokens").then((b) => pick(b, "authorized_tokens") as Grant[])); + if (r.ok) setTokens(r.data); + }, [run]); + useEffect(() => void load(), [load]); + + const revoke = async (id: string) => { + const r = await run(() => api(`/oauth/admin/authorized-tokens/${encodeURIComponent(id)}`, { method: "DELETE" }), "Grant revoked"); + if (r.ok) void load(); + }; + + return ( + + + Authorized grants + Every active refresh-token grant across all users in the tenant. + + +
+ + + + grant id + client + user + scopes + expires + Actions + + + + {tokens?.length === 0 && ( + + + No active grants. + + + )} + {tokens?.map((t) => ( + + {t.id} + {t.client_id} + {t.user_id} + {(t.scopes ?? []).join(" ") || "—"} + {t.expires_at} + + + + + ))} + +
+
+
+
+ ); +} diff --git a/plugins/OAuth2/ui/admin/Pages/OAuth2/Consent.tsx b/plugins/OAuth2/ui/admin/Pages/OAuth2/Consent.tsx new file mode 100644 index 0000000..00f0d23 --- /dev/null +++ b/plugins/OAuth2/ui/admin/Pages/OAuth2/Consent.tsx @@ -0,0 +1,61 @@ +import { usePage, Head } from "@pageflow/react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ui/card"; +import { Button } from "@ui/button"; + +// OAuth consent screen — a PLUGIN Pageflow page ("OAuth2/Consent", server: +// AuthorizationController@authorize). Approve/Deny use a NATIVE form POST to +// /oauth/authorize (not a Pageflow XHR) so the browser follows the 302 the +// decision() returns back to the client's redirect_uri. The kernel CSRF token +// rides in `_csrf_token` exactly like the old server-rendered form. + +type Props = { csrf: string; clientName: string; scopes: string[]; authzId: string }; + +export default function OAuth2Consent() { + const { props } = usePage(); + + return ( + <> + +
+ + +
🔐
+ + Authorize {props.clientName} + + {props.clientName} is requesting access to your account. +
+ +
+
This app will be able to
+
    + {props.scopes.length === 0 &&
  • Basic access to your account.
  • } + {props.scopes.map((s) => ( +
  • + + {s} +
  • + ))} +
+
+ +
+ + + + +
+ +

+ You can revoke access anytime in your account settings. +

+
+
+
+ + ); +} diff --git a/plugins/OAuth2/ui/admin/Pages/OAuth2/Simulate.tsx b/plugins/OAuth2/ui/admin/Pages/OAuth2/Simulate.tsx new file mode 100644 index 0000000..3ce4a23 --- /dev/null +++ b/plugins/OAuth2/ui/admin/Pages/OAuth2/Simulate.tsx @@ -0,0 +1,509 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Head, Link } from "@pageflow/react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ui/card"; +import { Button } from "@ui/button"; +import { Input } from "@ui/input"; +import { Label } from "@ui/label"; +import { Badge } from "@ui/badge"; +import type { AdminClientRow } from "@oauth2"; + +// Per-client OAuth simulation — a PLUGIN Pageflow page ("OAuth2/Simulate", server: +// AdminUiController@simulate). Two things in one page: +// • a PKCE demo (offline: verifier → challenge → server-verify), and +// • the REAL Authorization Code + PKCE flow — this same page is the redirect +// target (redirect_uri = {origin}/oauth/admin/simulate), so it launches +// /oauth/authorize and, on return, exchanges the code for real tokens. +// Session-authenticated, same-origin. crypto.subtle is unavailable over plain +// http, so SHA-256 is pure-JS below. + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function b64url(bytes: Uint8Array): string { + let s = ""; + for (const x of bytes) s += String.fromCharCode(x); + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function sha256(ascii: string): Uint8Array { + const rr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + const K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, + ]; + const bytes: number[] = []; + for (let i = 0; i < ascii.length; i++) { + const c = ascii.charCodeAt(i); + if (c < 0x80) bytes.push(c); + else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); + else bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); + } + const bitLen = bytes.length * 8; + bytes.push(0x80); + while (bytes.length % 64 !== 56) bytes.push(0); + bytes.push(0, 0, 0, 0, (bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff); + let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a; + let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19; + const w = new Array(64); + for (let i = 0; i < bytes.length; i += 64) { + for (let t = 0; t < 16; t++) + w[t] = (bytes[i + t * 4] << 24) | (bytes[i + t * 4 + 1] << 16) | (bytes[i + t * 4 + 2] << 8) | bytes[i + t * 4 + 3]; + for (let t = 16; t < 64; t++) { + const s0 = rr(w[t - 15], 7) ^ rr(w[t - 15], 18) ^ (w[t - 15] >>> 3); + const s1 = rr(w[t - 2], 17) ^ rr(w[t - 2], 19) ^ (w[t - 2] >>> 10); + w[t] = (w[t - 16] + s0 + w[t - 7] + s1) | 0; + } + let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7; + for (let t = 0; t < 64; t++) { + const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25); + const ch = (e & f) ^ (~e & g); + const t1 = (h + S1 + ch + K[t] + w[t]) | 0; + const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) | 0; + h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0; + } + h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0; + h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0; + } + const hs = [h0, h1, h2, h3, h4, h5, h6, h7]; + const out = new Uint8Array(32); + for (let i = 0; i < 8; i++) { + out[i * 4] = (hs[i] >>> 24) & 0xff; + out[i * 4 + 1] = (hs[i] >>> 16) & 0xff; + out[i * 4 + 2] = (hs[i] >>> 8) & 0xff; + out[i * 4 + 3] = hs[i] & 0xff; + } + return out; +} +const s256 = (v: string) => b64url(sha256(v)); +function genVerifier(len = 64): string { + const a = new Uint8Array(len); + crypto.getRandomValues(a); + const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + let s = ""; + for (const x of a) s += A[x % A.length]; + return s; +} +function copy(text: string) { + const noop = () => void 0; + if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(noop, noop); + else { + try { + const t = document.createElement("textarea"); + t.value = text; + t.style.position = "fixed"; + t.style.opacity = "0"; + document.body.appendChild(t); + t.select(); + document.execCommand("copy"); + document.body.removeChild(t); + } catch { + /* ignore */ + } + } +} +async function api(path: string, opts: RequestInit = {}): Promise { + const headers: Record = { Accept: "application/json" }; + if (opts.body) headers["Content-Type"] = "application/json"; + const res = await fetch(path, { credentials: "same-origin", headers, ...opts }); + const text = await res.text(); + let body: any = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + if (!res.ok && res.status !== 204) { + const msg = body?.error?.message || body?.error || body?.message || `HTTP ${res.status}`; + const e: any = new Error(msg); + e.status = res.status; + throw e; + } + return body; +} +const pick = (b: any, k: string): any[] => b?.data?.[k] ?? b?.[k] ?? []; + +type SimState = { verifier: string; clientId: string; redirect: string }; +const SKEY = "oauth2.sim.byState"; +function saveState(state: string, d: SimState) { + try { + const m = JSON.parse(sessionStorage.getItem(SKEY) || "{}"); + m[state] = d; + sessionStorage.setItem(SKEY, JSON.stringify(m)); + } catch { + /* ignore */ + } +} +function loadState(state: string): SimState | undefined { + try { + return (JSON.parse(sessionStorage.getItem(SKEY) || "{}") as Record)[state]; + } catch { + return undefined; + } +} + +export default function OAuth2Simulate() { + const [ready, setReady] = useState(false); + const [q, setQ] = useState({ client: "", code: "", state: "", error: "" }); + useEffect(() => { + const p = new URLSearchParams(window.location.search); + setQ({ client: p.get("client") || "", code: p.get("code") || "", state: p.get("state") || "", error: p.get("error") || "" }); + setReady(true); + }, []); + + return ( + <> + +
+
+
+ +
Authorization Code + PKCE (RFC 7636 / 6749)
+
+ {!ready ? null : q.code || q.error ? : } +
+
+ + ); +} + +// ── real callback: exchange the code for tokens ────────────────────────────── +function CallbackView({ code, state, error }: { code: string; state: string; error: string }) { + const [ok, setOk] = useState(null); + const [out, setOut] = useState("Exchanging authorization code…"); + const [clientId, setClientId] = useState(""); + + useEffect(() => { + if (error) { + setOk(false); + setOut(`Authorization error: ${error}`); + return; + } + const data = loadState(state); + if (!data) { + setOk(false); + setOut("No PKCE verifier stored for this state — launch the flow again from the simulator."); + return; + } + setClientId(data.clientId); + const form = new URLSearchParams({ + grant_type: "authorization_code", + client_id: data.clientId, + redirect_uri: data.redirect, + code, + code_verifier: data.verifier, + }); + fetch("/oauth/token", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: form.toString(), + }) + .then((r) => r.json()) + .then((b) => { + setOk(Boolean(b?.access_token)); + setOut(JSON.stringify(b, null, 2)); + }) + .catch((e) => { + setOk(false); + setOut(String(e)); + }); + }, [code, state, error]); + + return ( + + + + Token exchange {ok === true && success} + {ok === false && failed} + + + Posted code + code_verifier to /oauth/token. + + + +
+          {out}
+        
+ +
+
+ ); +} + +// ── PKCE demo + real-flow launcher ─────────────────────────────────────────── +function Simulator({ clientId }: { clientId: string }) { + const [method, setMethod] = useState<"S256" | "plain">("S256"); + const [verifier, setVerifier] = useState(""); + const [challenge, setChallenge] = useState(""); + const [state, setState] = useState(""); + const [stored, setStored] = useState(null); + + const [client, setClient] = useState(clientId ? "loading" : "missing"); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const simRedirect = `${origin}/oauth/admin/simulate`; + + useEffect(() => { + setVerifier(genVerifier(64)); + setState(genVerifier(24)); + }, []); + useEffect(() => { + setChallenge(!verifier ? "" : method === "plain" ? verifier : s256(verifier)); + }, [verifier, method]); + + const loadClient = useCallback(() => { + if (!clientId) return; + api("/oauth/admin/clients") + .then((b) => setClient((pick(b, "clients") as AdminClientRow[]).find((c) => c.id === clientId) ?? "missing")) + .catch((e) => { + setErr(e?.message ?? String(e)); + setClient("missing"); + }); + }, [clientId]); + useEffect(loadClient, [loadClient]); + + const obj = client && client !== "loading" && client !== "missing" ? client : null; + const registered = (obj?.redirect_uris ?? []).includes(simRedirect); + const scope = (obj?.scopes ?? []).join(" "); + + const play = () => { + const v = genVerifier(64); + setVerifier(v); + setState(genVerifier(24)); + const ch = method === "plain" ? v : s256(v); + setChallenge(ch); + setStored(ch); + }; + + const enableRedirect = async () => { + if (!obj) return; + setBusy(true); + setErr(""); + try { + await api(`/oauth/admin/clients/${encodeURIComponent(obj.id)}`, { + method: "PUT", + body: JSON.stringify({ name: obj.name, redirect_uris: [...(obj.redirect_uris ?? []), simRedirect], scopes: obj.scopes ?? [] }), + }); + loadClient(); + } catch (e: any) { + setErr(e?.message ?? String(e)); + } finally { + setBusy(false); + } + }; + + const launch = () => { + if (!obj) return; + saveState(state, { verifier, clientId: obj.id, redirect: simRedirect }); + const qs = new URLSearchParams({ + response_type: "code", + client_id: obj.id, + redirect_uri: simRedirect, + state, + code_challenge: challenge, + code_challenge_method: method, + }); + if (scope) qs.set("scope", scope); + window.location.assign(`/oauth/authorize?${qs.toString()}`); + }; + + const matches = stored === null ? null : challenge !== "" && challenge === stored; + const len = verifier.length; + const valid = len >= 43 && len <= 128 && /^[A-Za-z0-9\-._~]*$/.test(verifier); + + const authorizeUrl = useMemo(() => { + const qs = new URLSearchParams({ + response_type: "code", + client_id: clientId || "", + redirect_uri: obj ? simRedirect : "", + code_challenge: challenge || "…", + code_challenge_method: method, + state: state || "…", + }); + if (scope) qs.set("scope", scope); + return `/oauth/authorize?${qs.toString()}`; + }, [clientId, obj, simRedirect, challenge, method, state, scope]); + + return ( +
+
+

OAuth Simulator

+

+ code_challenge = BASE64URL(SHA256(code_verifier)) +

+
+ + offline demo — generates a fresh pair + state and verifies it +
+
+ + {/* Real flow */} + + + Run the real flow + + Launches /oauth/authorize with a registered web redirect and returns here to + exchange the code. + + + + {client === "loading" &&

Loading client…

} + {client === "missing" && ( +

+ {clientId ? ( + <>Client {clientId} not found{err ? ` — ${err}` : ""}. + ) : ( + <>Open this from Admin → “simulate” on a client to run the real flow. (The PKCE demo below works without one.) + )} +

+ )} + {obj && ( + <> + + + {err &&
{err}
} + {!registered ? ( +
+ This client hasn’t registered the simulation redirect yet. +
+ +
+
+ ) : ( + + )} + + )} +
+
+ + {/* PKCE pair */} + + +
+ 1 · The app generates a PKCE pair + The verifier stays on the device; only the challenge goes in the URL. +
+
+ + +
+
+ +
+
+ +
+ {len} chars + + +
+
+ setVerifier(e.target.value)} /> + {!valid && ( +

+ A verifier must be 43–128 chars from [A-Za-z0-9-._~]. +

+ )} +
+ +
+
+ + +
+
{challenge || "…"}
+
+ +
+
+ + +
+
{state || "…"}
+

A random per-request value the client generates and re-checks on the callback (CSRF defence).

+
+ +
+ +
GET {authorizeUrl}
+
+
+
+ + {/* server check */} + + + 2 · The server verifies at /token + + At /authorize the server stores the challenge; at{" "} + /token the app sends the verifier and the server recomputes it. + + + +
+ + {stored !== null && ( + + )} +
+ {stored !== null && ( +
+ + +
+ {matches + ? "✓ PKCE verified — the server issues access_token + refresh_token." + : "✕ invalid_grant — the verifier does not match the stored challenge."} +
+
+ )} +
+
+
+ ); +} + +function Kv({ k, v }: { k: string; v: string }) { + return ( +
+
{k}
+
{v || "…"}
+
+ ); +} diff --git a/plugins/OAuth2/ui/index.ts b/plugins/OAuth2/ui/index.ts new file mode 100644 index 0000000..50bfb16 --- /dev/null +++ b/plugins/OAuth2/ui/index.ts @@ -0,0 +1,32 @@ +// OAuth2 plugin UI entry — federated into the app frontend by `hkm ui sync` +// (mirrored to frontend/plugins/oauth2, aliased "@oauth2"). The admin surface +// globs plugins/*/admin/Pages/**, so admin/Pages/OAuth2/Admin.tsx resolves as +// the Pageflow component "OAuth2/Admin" (server: AdminUiController@dashboard). + +export type OwnerProfile = { + id: string; + username?: string; + email?: string; + full_name?: string; + avatar_url?: string | null; +}; + +export type AdminClientRow = { + id: string; + name: string; + redirect_uris?: string[]; + grant_types?: string[]; + scopes?: string[]; + confidential?: boolean; + revoked?: boolean; + owner_id?: string | null; + owner?: OwnerProfile | null; +}; + +export const OAUTH_GRANT_TYPES = [ + "authorization_code", + "refresh_token", + "client_credentials", + "password", + "urn:ietf:params:oauth:grant-type:device_code", +] as const; diff --git a/plugins/OAuth2/ui/ui.json b/plugins/OAuth2/ui/ui.json new file mode 100644 index 0000000..16ff1dc --- /dev/null +++ b/plugins/OAuth2/ui/ui.json @@ -0,0 +1,10 @@ +{ + "alias": "@oauth2", + "entry": "index.ts", + "framework": "react", + "surfaces": { + "admin": "admin/Pages", + "site": "site/Pages" + }, + "dependencies": {} +} diff --git a/projects/Infrastructure/FileCache.php b/projects/Infrastructure/FileCache.php new file mode 100644 index 0000000..e1098fc --- /dev/null +++ b/projects/Infrastructure/FileCache.php @@ -0,0 +1,243 @@ +read($this->file($key)); + + return $record === null ? null : $record['value']; + } + + public function set(string $key, mixed $value, ?int $ttl = null): bool + { + return $this->write($this->file($key), [ + 'key' => $key, + 'value' => $value, + 'expires' => $ttl !== null ? time() + $ttl : null, + ]); + } + + public function delete(string $key): bool + { + $file = $this->file($key); + + return !is_file($file) || @unlink($file); + } + + public function has(string $key): bool + { + return $this->read($this->file($key)) !== null; + } + + public function remember(string $key, int $ttl, callable $callback): mixed + { + $record = $this->read($this->file($key)); + if ($record !== null) { + return $record['value']; + } + + $value = $callback(); + $this->set($key, $value, $ttl); + + return $value; + } + + /** + * Atomic read-modify-write — the counter is held under one exclusive lock for + * the whole operation, so concurrent FPM workers cannot lose an increment + * (that would silently weaken every rate limiter built on this). + */ + public function increment(string $key, int $by = 1): int + { + $file = $this->file($key); + + $handle = @fopen($file, 'c+'); + if ($handle === false) { + return 0; + } + + try { + flock($handle, LOCK_EX); + + $raw = stream_get_contents($handle) ?: ''; + $record = $this->decode($raw); + $expires = $record['expires'] ?? null; + $current = $record === null ? 0 : (int) $record['value']; + $next = $current + $by; + + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, serialize(['key' => $key, 'value' => $next, 'expires' => $expires])); + fflush($handle); + + return $next; + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + public function deletePattern(string $pattern): int + { + $regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/'; + $count = 0; + + foreach ($this->files() as $file) { + $record = $this->read($file, deleteExpired: false); + // Match on the ORIGINAL key stored in the record — the filename is a + // hash and carries no pattern to match against. + if ($record !== null && preg_match($regex, (string) $record['key']) === 1 && @unlink($file)) { + $count++; + } + } + + return $count; + } + + public function flush(): bool + { + $ok = true; + + foreach ($this->files() as $file) { + $ok = @unlink($file) && $ok; + } + + return $ok; + } + + /** + * @return array{key: string, value: mixed, expires: int|null}|null null when + * missing, unreadable, corrupt or expired (expired files are reaped). + */ + private function read(string $file, bool $deleteExpired = true): ?array + { + if (!is_file($file)) { + return null; + } + + $handle = @fopen($file, 'r'); + if ($handle === false) { + return null; + } + + try { + flock($handle, LOCK_SH); + $raw = stream_get_contents($handle) ?: ''; + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + + $record = $this->decode($raw); + if ($record === null) { + return null; + } + + if ($record['expires'] !== null && $record['expires'] < time()) { + if ($deleteExpired) { + @unlink($file); + } + + return null; + } + + return $record; + } + + /** @return array{key: string, value: mixed, expires: int|null}|null */ + private function decode(string $raw): ?array + { + if ($raw === '') { + return null; + } + + $record = @unserialize($raw); + + return is_array($record) && array_key_exists('value', $record) + ? ['key' => (string) ($record['key'] ?? ''), 'value' => $record['value'], 'expires' => $record['expires'] ?? null] + : null; + } + + /** @param array{key: string, value: mixed, expires: int|null} $record */ + private function write(string $file, array $record): bool + { + // Write-then-rename: a concurrent reader never sees a half-written entry. + $temp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp'; + + if (@file_put_contents($temp, serialize($record), LOCK_EX) === false) { + return false; + } + + if (!@rename($temp, $file)) { + @unlink($temp); + + return false; + } + + // Group-writable: the web user (php-fpm) and the CLI user share this + // directory, and increment() reopens entries for writing in place. + @chmod($file, 0664); + + return true; + } + + private function file(string $key): string + { + return $this->directory() . '/' . sha1($key) . '.cache'; + } + + /** @return list */ + private function files(): array + { + $found = glob($this->directory() . '/*.cache'); + + return $found === false ? [] : $found; + } + + private function directory(): string + { + $dir = rtrim($this->dir, '/'); + + if (!is_dir($dir)) { + if (!@mkdir($dir, 0775, true) && !is_dir($dir)) { + throw new \RuntimeException("Cannot create cache directory: {$dir}"); + } + // setgid so entries stay in the parent's group no matter which user + // (php-fpm or CLI) created them. + @chmod($dir, 02775); + } + + return $dir; + } +} diff --git a/tools/ci/setup-zig.sh b/tools/ci/setup-zig.sh index fd4cb61..9f26c72 100755 --- a/tools/ci/setup-zig.sh +++ b/tools/ci/setup-zig.sh @@ -5,11 +5,19 @@ # The version in tools/.zig-version is a Zig *dev* build, which upstream purges # from ziglang.org/builds after a few months. To keep CI reproducible we fetch # it from a SELF-HOSTED GitHub release first (ZIG_DIST_URL), then fall back to -# the public mirrors for as long as they keep it. +# the community mirrors, which keep dev builds indefinitely. # # Publish the self-hosted copy once with tools/ci/publish-zig-toolchain.sh and # set the repo variable ZIG_DIST_URL to that release's download base, e.g. -# https://github.com/AlfaCode-Team/php-service-platform/releases/download/zig-toolchain +# https://github.com/AlfaCode-Team/hkm-kernel/releases/download/zig-toolchain +# +# NOTE: ZIG_DIST_URL is a repo variable, so it is EMPTY for pull requests from a +# fork. The public-mirror fallback is what keeps fork PRs green — it must work +# on its own, without any repo configuration. +# +# TARBALL NAMING: Zig flipped the tarball name from zig--- to +# zig--- as of 0.14.1. Both spellings are tried so this script +# keeps working whichever side of that change the pinned version sits on. # # Adds the extracted toolchain dir to GITHUB_PATH (or prints it locally). # --------------------------------------------------------------------------- @@ -29,32 +37,58 @@ case "$(uname -m)" in *) ZARCH=x86_64 ;; esac -VERSIONED="zig-${ZOS}-${ZARCH}-${VER}.tar.xz" # ziglang.org / mirror naming +# Upstream tarball names — current (arch first) and legacy (os first) spellings. +NAME_CURRENT="zig-${ZARCH}-${ZOS}-${VER}.tar.xz" +NAME_LEGACY="zig-${ZOS}-${ZARCH}-${VER}.tar.xz" SELFHOSTED="zig-${ZOS}-${ZARCH}.tar.xz" # release-asset naming (no '+') +# Public sources, in order. ziglang.org only serves the CURRENT master build, so +# it hits solely while the pin is fresh; the community mirrors below are the ones +# that still carry an aged dev build. Most community mirrors keep TAGGED releases +# only — the three here were verified against the current pin (machengine +# redirects to hexops; it stays as a second entry point to the same store). +# Full official list, worth re-checking when the pin changes: +# https://ziglang.org/download/community-mirrors.txt +bases=( + "https://ziglang.org/builds" + "https://pkg.hexops.org/zig" + "https://pkg.machengine.org/zig" + "https://zig.squirl.dev" +) + DEST="${RUNNER_TEMP:-/tmp}/zig-toolchain" mkdir -p "$DEST" TARBALL="$DEST/zig.tar.xz" -# Ordered candidate URLs: self-hosted first, then public mirrors. +# Ordered candidate URLs: self-hosted first, then every public base × both names. urls=() [ -n "${ZIG_DIST_URL:-}" ] && urls+=("${ZIG_DIST_URL%/}/${SELFHOSTED}") -urls+=("https://ziglang.org/builds/${VERSIONED}") -urls+=("https://pkg.machengine.org/zig/${VERSIONED}") +for b in "${bases[@]}"; do + urls+=("${b%/}/${NAME_CURRENT}" "${b%/}/${NAME_LEGACY}") +done fetched="" for u in "${urls[@]}"; do echo "▶ trying $u" - if curl -fSL --retry 3 --retry-delay 4 -o "$TARBALL" "$u"; then fetched="$u"; break; fi + # --connect-timeout keeps a dead mirror from stalling the job; --retry only + # fires on transient errors, so a 404 falls straight through to the next URL. + if curl -fSL --connect-timeout 15 --max-time 900 \ + --retry 3 --retry-delay 4 -o "$TARBALL" "$u"; then + fetched="$u"; break + fi done if [ -z "$fetched" ]; then echo "ERROR: could not fetch Zig $VER from any source." + echo " Tried ${#urls[@]} URLs (self-hosted + community mirrors, both namings)." if [ -z "${ZIG_DIST_URL:-}" ]; then - echo " The repo variable ZIG_DIST_URL is not set, and this pinned dev build has" - echo " been purged from the public mirrors. Publish the toolchain once with:" - echo " ZIG_HOME=/opt/zig ./tools/ci/publish-zig-toolchain.sh" - echo " then: gh variable set ZIG_DIST_URL -b ''" + echo " ZIG_DIST_URL is empty — expected for a fork PR (repo variables are not" + echo " exposed to them), so this run depended entirely on the public mirrors." fi + echo " If the mirrors have purged this dev build, host it yourself:" + echo " ZIG_HOME=/opt/zig ./tools/ci/publish-zig-toolchain.sh" + echo " gh variable set ZIG_DIST_URL -b ''" + echo " Fork PRs cannot use that — repin tools/.zig-version to a TAGGED release" + echo " (mirrored permanently) if fork contributions must build the launcher." exit 1 fi echo "✓ fetched from $fetched" @@ -65,8 +99,16 @@ ZIG_BIN="$(find "$DEST" -maxdepth 2 -type f -name zig | head -1)" [ -n "$ZIG_BIN" ] || { echo "ERROR: zig binary not found after extract"; exit 1; } ZIG_DIR="$(dirname "$ZIG_BIN")" +# Assert we got the pinned build — a mirror serving a different version must fail +# here, not silently compile the launcher with the wrong toolchain. +got="$("$ZIG_BIN" version)" +if [ "$got" != "$VER" ]; then + echo "ERROR: fetched Zig $got but tools/.zig-version pins $VER (source: $fetched)" + exit 1 +fi + if [ -n "${GITHUB_PATH:-}" ]; then echo "$ZIG_DIR" >> "$GITHUB_PATH" fi echo "Zig installed at $ZIG_DIR" -"$ZIG_BIN" version +echo "$got" From e7080609822ed2385a9811e1b113fa8d716c33b2 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 31 Jul 2026 18:34:45 +0300 Subject: [PATCH 094/140] feat: update OAuth2 module routes and remove mobile authentication endpoints --- plugins/Auth/module.json | 3 - plugins/OAuth2/module.json | 290 +++++++++++++++++++++++++++++-------- 2 files changed, 232 insertions(+), 61 deletions(-) diff --git a/plugins/Auth/module.json b/plugins/Auth/module.json index 9bdee90..0de359e 100644 --- a/plugins/Auth/module.json +++ b/plugins/Auth/module.json @@ -28,9 +28,6 @@ { "method": "POST", "path": "/auth/refresh", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\AuthTokenController@refresh", "filters": ["throttle:30,1"] }, { "method": "POST", "path": "/auth/refresh/logout", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\AuthTokenController@logout" }, - { "method": "POST", "path": "/auth/mobile/login", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@login", "filters": ["throttle:10,1"], "requires": ["oauth.server"] }, - { "method": "POST", "path": "/auth/mobile/register", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@register", "filters": ["throttle:6,1"], "requires": ["oauth.server"] }, - { "method": "POST", "path": "/auth/mobile/logout", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@logout", "filters": ["auth"] }, { "method": "POST", "path": "/auth/password/forgot", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PasswordResetController@forgot", "filters": ["throttle:5,1"], "requires": ["mail.delivery", "view.rendering"] }, { "method": "POST", "path": "/auth/password/verify-otp", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PasswordResetController@verifyOtp", "filters": ["throttle:10,1"] }, diff --git a/plugins/OAuth2/module.json b/plugins/OAuth2/module.json index 8683cd2..aa59e18 100644 --- a/plugins/OAuth2/module.json +++ b/plugins/OAuth2/module.json @@ -1,69 +1,243 @@ { - "name": "oauth2", - "version": "1.0.0", - "solves": "oauth.server", - "type": "module", + "name": "oauth2", + "version": "1.0.0", + "solves": "oauth.server", + "type": "module", - "requires": ["database.management", "crypto.services", "user.management", "view.rendering"], - "exposes": [ - "Plugins\\OAuth2\\Application\\Ports\\ClientStore", - "Plugins\\OAuth2\\Application\\Ports\\AuthorizationFlow" - ], + "requires": [ + "database.management", + "crypto.services", + "user.management", + "view.rendering", + "auth.identity" + ], + "exposes": [ + "Plugins\\OAuth2\\Application\\Ports\\ClientStore", + "Plugins\\OAuth2\\Application\\Ports\\AuthorizationFlow" + ], - "views": "resources/views", + "views": "resources/views", - "routes": [ - { "method": "GET", "path": "/oauth/authorize", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizationController@authorize", "requires": ["http.pageflow"] }, - { "method": "POST", "path": "/oauth/authorize", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizationController@decision", "requires": ["http.pageflow"] }, - { "method": "POST", "path": "/oauth/token", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\TokenController@issue", "filters": ["throttle:30,1"] }, - { "method": "POST", "path": "/oauth/device_authorization", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceController@authorize", "filters": ["throttle:30,1"] }, - { "method": "GET", "path": "/oauth/device", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceVerificationController@show" }, - { "method": "POST", "path": "/oauth/device", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceVerificationController@submit" }, - { "method": "GET", "path": "/oauth/userinfo", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\UserInfoController@show", "filters": ["auth"] }, - { "method": "POST", "path": "/oauth/introspect", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\IntrospectionController@introspect" }, - { "method": "POST", "path": "/oauth/revoke", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\IntrospectionController@revoke" }, - { "method": "GET", "path": "/oauth/jwks", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\JwksController@keys" }, + "routes": [ + { + "method": "POST", + "path": "/auth/mobile/login", + "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@login", + "filters": ["throttle:10,1"], + "requires": ["oauth.server"] + }, + { + "method": "POST", + "path": "/auth/mobile/register", + "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@register", + "filters": ["throttle:6,1"], + "requires": ["oauth.server"] + }, + { + "method": "POST", + "path": "/auth/mobile/logout", + "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@logout", + "filters": ["auth"] + }, - { "method": "GET", "path": "/oauth/scopes", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ScopeController@index" }, - { "method": "GET", "path": "/oauth/clients", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@forUser", "filters": ["auth"] }, - { "method": "POST", "path": "/oauth/clients", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@store", "filters": ["auth", "throttle:20,1"] }, - { "method": "PUT", "path": "/oauth/clients/{id}", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@update", "filters": ["auth"] }, - { "method": "DELETE", "path": "/oauth/clients/{id}", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@destroy", "filters": ["auth"] }, - { "method": "GET", "path": "/oauth/authorized-tokens", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizedTokenController@forUser", "filters": ["auth"] }, - { "method": "DELETE", "path": "/oauth/authorized-tokens/{id}", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizedTokenController@destroy", "filters": ["auth"] }, + { + "method": "GET", + "path": "/oauth/authorize", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizationController@authorize", + "requires": ["http.pageflow"] + }, + { + "method": "POST", + "path": "/oauth/authorize", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizationController@decision", + "requires": ["http.pageflow"] + }, + { + "method": "POST", + "path": "/oauth/token", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\TokenController@issue", + "filters": ["throttle:30,1"] + }, + { + "method": "POST", + "path": "/oauth/device_authorization", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceController@authorize", + "filters": ["throttle:30,1"] + }, + { + "method": "GET", + "path": "/oauth/device", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceVerificationController@show" + }, + { + "method": "POST", + "path": "/oauth/device", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceVerificationController@submit" + }, + { + "method": "GET", + "path": "/oauth/userinfo", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\UserInfoController@show", + "filters": ["auth"] + }, + { + "method": "POST", + "path": "/oauth/introspect", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\IntrospectionController@introspect" + }, + { + "method": "POST", + "path": "/oauth/revoke", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\IntrospectionController@revoke" + }, + { + "method": "GET", + "path": "/oauth/jwks", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\JwksController@keys" + }, - { "method": "GET", "path": "/oauth/admin", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminUiController@dashboard", "requires": ["http.pageflow"] }, - { "method": "GET", "path": "/oauth/admin/simulate", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminUiController@simulate", "requires": ["http.pageflow"] }, + { + "method": "GET", + "path": "/oauth/scopes", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ScopeController@index" + }, + { + "method": "GET", + "path": "/oauth/clients", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@forUser", + "filters": ["auth"] + }, + { + "method": "POST", + "path": "/oauth/clients", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@store", + "filters": ["auth", "throttle:20,1"] + }, + { + "method": "PUT", + "path": "/oauth/clients/{id}", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@update", + "filters": ["auth"] + }, + { + "method": "DELETE", + "path": "/oauth/clients/{id}", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@destroy", + "filters": ["auth"] + }, + { + "method": "GET", + "path": "/oauth/authorized-tokens", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizedTokenController@forUser", + "filters": ["auth"] + }, + { + "method": "DELETE", + "path": "/oauth/authorized-tokens/{id}", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizedTokenController@destroy", + "filters": ["auth"] + }, - { "method": "GET", "path": "/oauth/admin/clients", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@clients", "filters": ["auth"] }, - { "method": "POST", "path": "/oauth/admin/clients", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@createClient", "filters": ["auth", "throttle:20,1"] }, - { "method": "PUT", "path": "/oauth/admin/clients/{id}", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@updateClient", "filters": ["auth"] }, - { "method": "POST", "path": "/oauth/admin/clients/{id}/rotate", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@rotateClient", "filters": ["auth"] }, - { "method": "DELETE", "path": "/oauth/admin/clients/{id}", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@revokeClient", "filters": ["auth"] }, - { "method": "GET", "path": "/oauth/admin/scopes", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@scopes", "filters": ["auth"] }, - { "method": "POST", "path": "/oauth/admin/scopes", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@createScope", "filters": ["auth"] }, - { "method": "DELETE", "path": "/oauth/admin/scopes/{id}", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@deleteScope", "filters": ["auth"] }, - { "method": "GET", "path": "/oauth/admin/authorized-tokens", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@authorizedTokens", "filters": ["auth"] }, - { "method": "DELETE", "path": "/oauth/admin/authorized-tokens/{id}", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@revokeToken", "filters": ["auth"] }, - { "method": "GET", "path": "/.well-known/oauth-authorization-server", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DiscoveryController@metadata" }, - { "method": "GET", "path": "/.well-known/openid-configuration", "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DiscoveryController@openidConfiguration" } - ], + { + "method": "GET", + "path": "/oauth/admin", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminUiController@dashboard", + "requires": ["http.pageflow"] + }, + { + "method": "GET", + "path": "/oauth/admin/simulate", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminUiController@simulate", + "requires": ["http.pageflow"] + }, - "emits": [], - "listens": [], + { + "method": "GET", + "path": "/oauth/admin/clients", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@clients", + "filters": ["auth"] + }, + { + "method": "POST", + "path": "/oauth/admin/clients", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@createClient", + "filters": ["auth", "throttle:20,1"] + }, + { + "method": "PUT", + "path": "/oauth/admin/clients/{id}", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@updateClient", + "filters": ["auth"] + }, + { + "method": "POST", + "path": "/oauth/admin/clients/{id}/rotate", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@rotateClient", + "filters": ["auth"] + }, + { + "method": "DELETE", + "path": "/oauth/admin/clients/{id}", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@revokeClient", + "filters": ["auth"] + }, + { + "method": "GET", + "path": "/oauth/admin/scopes", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@scopes", + "filters": ["auth"] + }, + { + "method": "POST", + "path": "/oauth/admin/scopes", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@createScope", + "filters": ["auth"] + }, + { + "method": "DELETE", + "path": "/oauth/admin/scopes/{id}", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@deleteScope", + "filters": ["auth"] + }, + { + "method": "GET", + "path": "/oauth/admin/authorized-tokens", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@authorizedTokens", + "filters": ["auth"] + }, + { + "method": "DELETE", + "path": "/oauth/admin/authorized-tokens/{id}", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@revokeToken", + "filters": ["auth"] + }, + { + "method": "GET", + "path": "/.well-known/oauth-authorization-server", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DiscoveryController@metadata" + }, + { + "method": "GET", + "path": "/.well-known/openid-configuration", + "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DiscoveryController@openidConfiguration" + } + ], - "documentation": "Native OAuth 2.1 authorization server. Grants: authorization_code (+PKCE), client_credentials, refresh_token, password. Access tokens are JWTs signed with the platform JWT keys (verified by Plugins\\Auth JwtAuthLayer). Endpoints under /oauth/* plus RFC 8414 discovery. Control-plane tables (oauth_clients/oauth_auth_codes/oauth_refresh_tokens/oauth_scopes) pinned to central. CLI: oauth:client:create.", + "emits": [], + "listens": [], - "config": [ - { "key": "OAUTH_ACCESS_TTL", "type": "int", "required": false }, - { "key": "OAUTH_REFRESH_TTL", "type": "int", "required": false }, - { "key": "OAUTH_CODE_TTL", "type": "int", "required": false }, - { "key": "OAUTH_DEVICE_TTL", "type": "int", "required": false }, - { "key": "OAUTH_DEVICE_INTERVAL", "type": "int", "required": false }, - { "key": "OAUTH_TOKEN_AUDIENCE", "type": "string", "required": false }, - { "key": "OAUTH_ADMIN_ROLE", "type": "string", "required": false }, - { "key": "OAUTH_ADMIN_USERS", "type": "string", "required": false }, - { "key": "JWT_PUBLIC_KEY", "type": "string", "required": false }, - { "key": "JWT_PUBLIC_KEY_FILE", "type": "string", "required": false } - ] + "documentation": "Native OAuth 2.1 authorization server. Grants: authorization_code (+PKCE), client_credentials, refresh_token, password. Access tokens are JWTs signed with the platform JWT keys (verified by Plugins\\Auth JwtAuthLayer). Endpoints under /oauth/* plus RFC 8414 discovery. Control-plane tables (oauth_clients/oauth_auth_codes/oauth_refresh_tokens/oauth_scopes) pinned to central. CLI: oauth:client:create.", + + "config": [ + { "key": "OAUTH_ACCESS_TTL", "type": "int", "required": false }, + { "key": "OAUTH_REFRESH_TTL", "type": "int", "required": false }, + { "key": "OAUTH_CODE_TTL", "type": "int", "required": false }, + { "key": "OAUTH_DEVICE_TTL", "type": "int", "required": false }, + { "key": "OAUTH_DEVICE_INTERVAL", "type": "int", "required": false }, + { "key": "OAUTH_TOKEN_AUDIENCE", "type": "string", "required": false }, + { "key": "OAUTH_ADMIN_ROLE", "type": "string", "required": false }, + { "key": "OAUTH_ADMIN_USERS", "type": "string", "required": false }, + { "key": "JWT_PUBLIC_KEY", "type": "string", "required": false }, + { "key": "JWT_PUBLIC_KEY_FILE", "type": "string", "required": false } + ] } From 7289e61b63e066a498f7d69ad92a95d7e82bd393 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 31 Jul 2026 18:50:41 +0300 Subject: [PATCH 095/140] chore: remove unused submodules for module-template and pulse-engine --- modules/module-template | 1 - modules/pulse-engine | 1 - 2 files changed, 2 deletions(-) delete mode 160000 modules/module-template delete mode 160000 modules/pulse-engine diff --git a/modules/module-template b/modules/module-template deleted file mode 160000 index ae024ef..0000000 --- a/modules/module-template +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ae024ef4e4aebaa5f764e1a2aca23258fa5f5ae4 diff --git a/modules/pulse-engine b/modules/pulse-engine deleted file mode 160000 index a747914..0000000 --- a/modules/pulse-engine +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a74791407db63cee34ed744e2bea60faece89308 From 13a5a315221d7c7c7a3a1e745bbf8083eca616b0 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 6 Aug 2026 04:03:41 +0300 Subject: [PATCH 096/140] feat(kernel): add config repository, logger port, locks, typed routes, url generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight kernel additions derived from a gap analysis against HKM 0.3 (docs/migration/KERNEL-ADDITIONS.md). All additive and backward compatible — no existing module.json, proj.json or bootstrap needed editing. - Config\Repository + CompileConfigManifestStage (boot stage 9) + config() Dotted, immutable, project deep-merged over plugin per KEY rather than replacing the whole file. - Ports\LoggerPort + LogLevel enum The only binding of Psr\Log\LoggerInterface in the codebase pointed at a NullLogger, so every line from Database, Tenancy, EventBus and the command auditor was silently discarded. - Ports\Lock + AbstractLock + CachePort::lock()/restoreLock() increment() is atomic but cannot express ownership, blocking acquisition or TTL-bounded release, so single-flight cron and job idempotency had no correct implementation. Release must be atomic compare-and-delete. - Routing\RouteParameter — typed {id:num} params, unknown type fails the boot Previously every {param} compiled to [^/]+, so /users/{id} matched /users/abc. - Routing\UrlGenerator — named routes + signed URLs A project override inherits the plugin route's name, so a plugin view linking to route('auth.register') survives the project replacing that page. - QueuePort pop/ack/release/fail + WorkerLoop port mode The port was write-only; consumption leaked through a caller-supplied puller. - Exceptions\HttpStatusAware — non-kernel exceptions were all 500 + CRITICAL. - Ports\ClockPort + SystemClock — CSRF expiry was untestable without sleeping. Tests: 532 -> 659 (+127). Pre-existing failures unchanged (77 ext-sqlite3 errors, 5 php-io-cli CLI failures). --- projects/Infrastructure/FileCache.php | 21 ++ projects/Infrastructure/FileLock.php | 140 +++++++++ projects/Infrastructure/FileQueue.php | 76 ++++- projects/Infrastructure/InMemoryCache.php | 25 ++ projects/Infrastructure/ProcessLocalLock.php | 86 ++++++ src/Kernel/Boot/BootPipeline.php | 6 +- src/Kernel/Boot/ManifestReader.php | 23 ++ .../Stages/CompileConfigManifestStage.php | 153 ++++++++++ .../Boot/Stages/CompileRouteManifestStage.php | 102 +++++++ src/Kernel/Config/Repository.php | 112 +++++++ src/Kernel/Container/CoreContainer.php | 16 +- src/Kernel/Events/EventBus.php | 17 +- src/Kernel/Exceptions/HttpStatusAware.php | 39 +++ .../Exceptions/LockTimeoutException.php | 24 ++ src/Kernel/Kernel.php | 37 ++- src/Kernel/Pipelines/Http/RouteMatcher.php | 19 +- .../Pipelines/Http/Stages/ErrorStage.php | 15 + src/Kernel/Pipelines/Worker/WorkerLoop.php | 81 ++++- src/Kernel/Ports/AbstractLock.php | 90 ++++++ src/Kernel/Ports/CachePort.php | 19 ++ src/Kernel/Ports/ClockPort.php | 32 ++ src/Kernel/Ports/Lock.php | 89 ++++++ src/Kernel/Ports/LogLevel.php | 53 ++++ src/Kernel/Ports/LoggerPort.php | 73 +++++ src/Kernel/Ports/QueuePort.php | 57 +++- src/Kernel/Ports/SystemClock.php | 25 ++ src/Kernel/Routing/RouteParameter.php | 120 ++++++++ src/Kernel/Routing/UrlGenerator.php | 280 ++++++++++++++++++ src/Kernel/Security/Layers/CsrfTokenLayer.php | 26 +- src/Kernel/Security/SecurityGateway.php | 13 +- src/Kernel/Support/helpers.php | 31 ++ .../Kernel/Boot/RouteNameCompilationTest.php | 173 +++++++++++ .../Boot/RouteParameterValidationTest.php | 101 +++++++ .../Config/CompileConfigManifestStageTest.php | 125 ++++++++ .../Unit/Kernel/Config/ConfigCommandsTest.php | 127 ++++++++ tests/Unit/Kernel/Config/RepositoryTest.php | 94 ++++++ .../Kernel/Exceptions/HttpStatusAwareTest.php | 81 +++++ .../Pipelines/Http/RouteMatcherTest.php | 155 ++++++++++ tests/Unit/Kernel/Ports/LockContractTest.php | 180 +++++++++++ tests/Unit/Kernel/Ports/LoggerPortTest.php | 155 ++++++++++ tests/Unit/Kernel/Ports/QueuePortTest.php | 155 ++++++++++ .../Unit/Kernel/Routing/UrlGeneratorTest.php | 179 +++++++++++ .../Kernel/Security/CsrfTokenExpiryTest.php | 117 ++++++++ 43 files changed, 3503 insertions(+), 39 deletions(-) create mode 100644 projects/Infrastructure/FileLock.php create mode 100644 projects/Infrastructure/ProcessLocalLock.php create mode 100644 src/Kernel/Boot/Stages/CompileConfigManifestStage.php create mode 100644 src/Kernel/Config/Repository.php create mode 100644 src/Kernel/Exceptions/HttpStatusAware.php create mode 100644 src/Kernel/Exceptions/LockTimeoutException.php create mode 100644 src/Kernel/Ports/AbstractLock.php create mode 100644 src/Kernel/Ports/ClockPort.php create mode 100644 src/Kernel/Ports/Lock.php create mode 100644 src/Kernel/Ports/LogLevel.php create mode 100644 src/Kernel/Ports/LoggerPort.php create mode 100644 src/Kernel/Ports/SystemClock.php create mode 100644 src/Kernel/Routing/RouteParameter.php create mode 100644 src/Kernel/Routing/UrlGenerator.php create mode 100644 tests/Unit/Kernel/Boot/RouteNameCompilationTest.php create mode 100644 tests/Unit/Kernel/Boot/RouteParameterValidationTest.php create mode 100644 tests/Unit/Kernel/Config/CompileConfigManifestStageTest.php create mode 100644 tests/Unit/Kernel/Config/ConfigCommandsTest.php create mode 100644 tests/Unit/Kernel/Config/RepositoryTest.php create mode 100644 tests/Unit/Kernel/Exceptions/HttpStatusAwareTest.php create mode 100644 tests/Unit/Kernel/Pipelines/Http/RouteMatcherTest.php create mode 100644 tests/Unit/Kernel/Ports/LockContractTest.php create mode 100644 tests/Unit/Kernel/Ports/LoggerPortTest.php create mode 100644 tests/Unit/Kernel/Ports/QueuePortTest.php create mode 100644 tests/Unit/Kernel/Routing/UrlGeneratorTest.php create mode 100644 tests/Unit/Kernel/Security/CsrfTokenExpiryTest.php diff --git a/projects/Infrastructure/FileCache.php b/projects/Infrastructure/FileCache.php index e1098fc..fee40fb 100644 --- a/projects/Infrastructure/FileCache.php +++ b/projects/Infrastructure/FileCache.php @@ -5,6 +5,7 @@ namespace Project\Infrastructure; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\Lock; /** * FileCache — a dependency-free, CROSS-PROCESS CachePort adapter. @@ -225,6 +226,26 @@ private function files(): array return $found === false ? [] : $found; } + /** + * Cross-process lock, consistent with this store's cross-process guarantee. + * Locks live in a sibling `locks/` directory so flush()/deletePattern(), + * which glob `*.cache`, never sweep a held lock away. + */ + public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock + { + return new FileLock($this->lockDirectory(), $name, $seconds, $owner); + } + + public function restoreLock(string $name, string $owner): Lock + { + return new FileLock($this->lockDirectory(), $name, 0, $owner); + } + + private function lockDirectory(): string + { + return rtrim($this->dir, '/') . '/locks'; + } + private function directory(): string { $dir = rtrim($this->dir, '/'); diff --git a/projects/Infrastructure/FileLock.php b/projects/Infrastructure/FileLock.php new file mode 100644 index 0000000..5917538 --- /dev/null +++ b/projects/Infrastructure/FileLock.php @@ -0,0 +1,140 @@ +file(); + + // Reclaim an expired lock so a crashed holder does not block forever. + $existing = $this->readRecord($file); + if ($existing !== null && $this->isExpired($existing)) { + @unlink($file); + } + + // Atomic create-if-absent. Anyone losing the race gets false here. + $handle = @fopen($file, 'x'); + if ($handle === false) { + return false; + } + + try { + $expires = $this->seconds > 0 ? \time() + $this->seconds : 0; + \fwrite($handle, $this->owner . '|' . $expires); + } finally { + \fclose($handle); + } + + @\chmod($file, 0664); + + return true; + } + + public function release(): bool + { + $file = $this->file(); + if (!\is_file($file)) { + return false; + } + + $handle = @fopen($file, 'c+'); + if ($handle === false) { + return false; + } + + try { + // Exclusive: makes compare-then-delete atomic against other holders. + if (!\flock($handle, LOCK_EX)) { + return false; + } + + $raw = (string) \stream_get_contents($handle); + $owner = \explode('|', $raw)[0] ?? ''; + + // hash_equals: owner tokens are secrets — never compare with ===. + if (!\hash_equals($this->owner, $owner)) { + return false; + } + + @\unlink($file); + + return true; + } finally { + \flock($handle, LOCK_UN); + \fclose($handle); + } + } + + public function forceRelease(): void + { + @\unlink($this->file()); + } + + /** @return array{owner: string, expires: int}|null */ + private function readRecord(string $file): ?array + { + if (!\is_file($file)) { + return null; + } + $raw = @\file_get_contents($file); + if ($raw === false || $raw === '') { + return null; + } + $parts = \explode('|', $raw); + + return ['owner' => $parts[0] ?? '', 'expires' => (int) ($parts[1] ?? 0)]; + } + + /** @param array{owner: string, expires: int} $record */ + private function isExpired(array $record): bool + { + // expires === 0 means "no TTL" — never reclaimed automatically. + return $record['expires'] > 0 && $record['expires'] < \time(); + } + + private function file(): string + { + $dir = \rtrim($this->dir, '/'); + if (!\is_dir($dir) && !@\mkdir($dir, 0775, true) && !\is_dir($dir)) { + throw new \RuntimeException("Cannot create lock directory: {$dir}"); + } + + return $dir . '/' . \sha1($this->name) . '.lock'; + } +} diff --git a/projects/Infrastructure/FileQueue.php b/projects/Infrastructure/FileQueue.php index d22e7fd..40ff710 100644 --- a/projects/Infrastructure/FileQueue.php +++ b/projects/Infrastructure/FileQueue.php @@ -4,6 +4,7 @@ namespace Project\Infrastructure; +use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\JobPayload; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\QueuePort; /** @@ -18,7 +19,7 @@ * Not for high throughput: writes take an exclusive lock and pop rewrites the * file. It is a correct, simple default — not a broker. * - * QueuePort has no pop(); the project's worker puller calls {@see pop()} on this + * Implements the full QueuePort lifecycle (push/pop/ack/release/fail) on this * concrete adapter and rebuilds a JobPayload from the record. */ final class FileQueue implements QueuePort @@ -67,13 +68,84 @@ public function size(string $queue = 'default'): int return $lines === false ? 0 : count($lines); } + /** + * Reserve the next due job, or null when the queue is empty. + * + * Implements the QueuePort read side. Removes the record from the file under + * an exclusive lock, so two workers cannot take the same job. + */ + public function pop(string $queue = 'default'): ?JobPayload + { + $record = $this->popRecord($queue); + + return $record === null ? null : $this->hydrate($record, $queue); + } + + /** + * The job completed — nothing to do, popRecord() already removed the line. + * Explicit so the four-verb lifecycle reads the same across adapters. + */ + public function ack(JobPayload $payload): void + { + } + + /** Re-append with an incremented attempt count, available again after $delay. */ + public function release(JobPayload $payload, int $delay = 0): void + { + $this->append($payload->queue(), [ + 'jobId' => $payload->jobId(), + 'jobClass' => $payload->jobClass(), + 'data' => $payload->data(), + 'queue' => $payload->queue(), + 'attempts' => $payload->attempts() + 1, + 'maxAttempts' => $payload->maxAttempts(), + 'enqueuedAt' => $payload->enqueuedAt()->format(\DateTimeInterface::ATOM), + 'availableAt' => time() + max(0, $delay), + ]); + } + + /** + * Write to a sibling dead-letter file rather than dropping the job. A + * permanently-failing job that simply vanishes is the hardest kind of bug + * to notice. + */ + public function fail(JobPayload $payload, ?\Throwable $reason = null): void + { + $this->append($payload->queue() . '.failed', [ + 'jobId' => $payload->jobId(), + 'jobClass' => $payload->jobClass(), + 'data' => $payload->data(), + 'queue' => $payload->queue(), + 'attempts' => $payload->attempts(), + 'maxAttempts' => $payload->maxAttempts(), + 'enqueuedAt' => $payload->enqueuedAt()->format(\DateTimeInterface::ATOM), + 'failedAt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM), + 'error' => $reason?->getMessage(), + ]); + } + + /** @param array $record */ + private function hydrate(array $record, string $queue): JobPayload + { + return new JobPayload( + jobId: (string) ($record['jobId'] ?? ''), + jobClass: (string) ($record['jobClass'] ?? ''), + data: (array) ($record['data'] ?? []), + queue: (string) ($record['queue'] ?? $queue), + attempts: (int) ($record['attempts'] ?? 0), + maxAttempts: (int) ($record['maxAttempts'] ?? $this->defaultMaxAttempts), + enqueuedAt: new \DateTimeImmutable((string) ($record['enqueuedAt'] ?? 'now')), + signature: (string) ($record['signature'] ?? ''), + ); + } + /** * Pop the next due record (FIFO), or null when the queue is empty. Rewrites * the file without the popped line under an exclusive lock. * * @return array|null */ - public function pop(string $queue = 'default'): ?array + private function popRecord(string $queue = 'default'): ?array { $file = $this->file($queue); diff --git a/projects/Infrastructure/InMemoryCache.php b/projects/Infrastructure/InMemoryCache.php index 73d7ca8..036d291 100644 --- a/projects/Infrastructure/InMemoryCache.php +++ b/projects/Infrastructure/InMemoryCache.php @@ -4,6 +4,7 @@ namespace Project\Infrastructure; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\Lock; /** * InMemoryCache — project-supplied CachePort adapter (PROJECT LAYER). @@ -22,6 +23,14 @@ final class InMemoryCache implements CachePort /** @var array */ private array $store = []; + /** + * Lock table, shared with every {@see ProcessLocalLock} this cache hands out. + * Public because the lock reads/writes it directly; not part of CachePort. + * + * @var array + */ + public array $locks = []; + public function get(string $key): mixed { if (!$this->has($key)) { @@ -93,6 +102,22 @@ public function deletePattern(string $pattern): int public function flush(): bool { $this->store = []; + $this->locks = []; return true; } + + /** + * ⚠️ Process-local only — see {@see ProcessLocalLock}. Under PHP-FPM two + * concurrent requests will BOTH acquire this lock. Use FileCache (FileLock) + * or the RedisCache plugin for anything that must hold across processes. + */ + public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock + { + return new ProcessLocalLock($this, $name, $seconds, $owner); + } + + public function restoreLock(string $name, string $owner): Lock + { + return new ProcessLocalLock($this, $name, 0, $owner); + } } diff --git a/projects/Infrastructure/ProcessLocalLock.php b/projects/Infrastructure/ProcessLocalLock.php new file mode 100644 index 0000000..9d14607 --- /dev/null +++ b/projects/Infrastructure/ProcessLocalLock.php @@ -0,0 +1,86 @@ +} $registry + * the owning cache instance, so every lock it hands out shares one table + */ + public function __construct( + private readonly object $registry, + string $name, + int $seconds, + ?string $owner = null, + ) { + parent::__construct($name, $seconds, $owner ?? self::randomOwner()); + } + + public function acquire(): bool + { + $current = $this->registry->locks[$this->name] ?? null; + + if ($current !== null && !$this->isExpired($current)) { + return false; + } + + $this->registry->locks[$this->name] = [ + 'owner' => $this->owner, + 'expires' => $this->seconds > 0 ? \time() + $this->seconds : 0, + ]; + + return true; + } + + public function release(): bool + { + $current = $this->registry->locks[$this->name] ?? null; + + if ($current === null || !\hash_equals($this->owner, $current['owner'])) { + return false; + } + + unset($this->registry->locks[$this->name]); + + return true; + } + + public function forceRelease(): void + { + unset($this->registry->locks[$this->name]); + } + + /** @param array{owner: string, expires: int} $record */ + private function isExpired(array $record): bool + { + return $record['expires'] > 0 && $record['expires'] < \time(); + } +} diff --git a/src/Kernel/Boot/BootPipeline.php b/src/Kernel/Boot/BootPipeline.php index c4401d5..367480b 100644 --- a/src/Kernel/Boot/BootPipeline.php +++ b/src/Kernel/Boot/BootPipeline.php @@ -15,6 +15,7 @@ CompileViewManifestStage, CompileJobManifestStage, CompileCommandManifestStage, + CompileConfigManifestStage, RegisterPortsStage, BindSecurityStage }; @@ -64,8 +65,9 @@ public function __construct( new CompileViewManifestStage($moduleClasses, reader: $reader), // 6. views[] → view-manifest.php (project-first cascade) new CompileJobManifestStage($moduleClasses, reader: $reader), // 7. jobs[] → job-manifest.php new CompileCommandManifestStage($moduleClasses, reader: $reader), // 8. commands[] → command-manifest.php - new RegisterPortsStage($core), // 9. Port → Adapter bindings validated - new BindSecurityStage($securityLayers), // 10. SecurityGateway layers validated + new CompileConfigManifestStage($moduleClasses, reader: $reader), // 9. config/*.php → config-manifest.php (project over plugin) + new RegisterPortsStage($core), // 10. Port → Adapter bindings validated + new BindSecurityStage($securityLayers), // 11. SecurityGateway layers validated ]; } diff --git a/src/Kernel/Boot/ManifestReader.php b/src/Kernel/Boot/ManifestReader.php index 3363a45..d587366 100644 --- a/src/Kernel/Boot/ManifestReader.php +++ b/src/Kernel/Boot/ManifestReader.php @@ -45,4 +45,27 @@ public function read(string $moduleClass): array return $this->cache[$moduleClass] = $decoded; } + + /** + * Read a COMPILED manifest written by {@see ManifestWriter} (route-manifest.php, + * config-manifest.php, …). The counterpart to ManifestWriter::write(). + * + * A missing manifest returns $default rather than throwing: a surface that + * never compiled its manifest (an HTTP-only process has no job manifest) must + * not fail, and boot compiles every manifest before anything reads one. + * + * @param array $default + * @return array + */ + public static function readCompiled(string $file, array $default = []): array + { + $path = \AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths::cache('manifests/' . ltrim($file, '/')); + if (!is_file($path)) { + return $default; + } + + $data = require $path; + + return is_array($data) ? $data : $default; + } } diff --git a/src/Kernel/Boot/Stages/CompileConfigManifestStage.php b/src/Kernel/Boot/Stages/CompileConfigManifestStage.php new file mode 100644 index 0000000..5cb26e7 --- /dev/null +++ b/src/Kernel/Boot/Stages/CompileConfigManifestStage.php @@ -0,0 +1,153 @@ + [...], 'validation' => [...], 'storage' => [...] ] + * + * Group name = the file's basename. `config/mail.php` is read as `mail.*`. + */ +final class CompileConfigManifestStage implements BootStageContract +{ + /** @param list $moduleClasses */ + public function __construct( + private readonly array $moduleClasses, + private readonly ManifestReader $reader = new ManifestReader(), + ) {} + + public function run(): void + { + $config = []; + + // ── PLUGIN config (defaults) ───────────────────────────────────────── + foreach ($this->moduleClasses as $moduleClass) { + foreach ($this->filesIn($this->moduleDir($moduleClass) . '/config') as $group => $file) { + $loaded = $this->load($file); + $config[$group] = isset($config[$group]) && is_array($config[$group]) + ? $this->merge($config[$group], $loaded) + : $loaded; + } + } + + // ── PROJECT config (overrides) ─────────────────────────────────────── + // Deep-merged LAST so a project always wins, and only for the keys it + // actually names. + foreach ($this->filesIn(Paths::config()) as $group => $file) { + $loaded = $this->load($file); + $config[$group] = isset($config[$group]) && is_array($config[$group]) + ? $this->merge($config[$group], $loaded) + : $loaded; + } + + ManifestWriter::write('config-manifest.php', $config); + } + + /** + * Every `*.php` in a config directory, keyed by group name. + * + * @return array group => absolute file path + */ + private function filesIn(string $dir): array + { + if (!is_dir($dir)) { + return []; + } + + $found = glob(rtrim($dir, '/') . '/*.php'); + if ($found === false) { + return []; + } + + sort($found); // deterministic order regardless of filesystem listing + + $files = []; + foreach ($found as $file) { + $files[basename($file, '.php')] = $file; + } + + return $files; + } + + /** @return array */ + private function load(string $file): array + { + $value = require $file; + + if (!is_array($value)) { + throw new BootException( + "Config file [{$file}] must return an array, got " . get_debug_type($value) . '.' + ); + } + + return $value; + } + + /** + * Recursive merge with LIST-REPLACE semantics. + * + * Associative arrays merge key by key (so an override names only what it + * changes). Lists are replaced wholesale — appending would leave no way to + * remove a default entry, which is usually the point of overriding a list. + * + * @param array $base + * @param array $override + * @return array + */ + private function merge(array $base, array $override): array + { + foreach ($override as $key => $value) { + if ( + is_array($value) + && isset($base[$key]) + && is_array($base[$key]) + && !array_is_list($value) + ) { + $base[$key] = $this->merge($base[$key], $value); + continue; + } + $base[$key] = $value; + } + + return $base; + } + + private function moduleDir(string $moduleClass): string + { + $file = (new \ReflectionClass($moduleClass))->getFileName(); + if ($file === false) { + throw new BootException("Cannot locate source file for [{$moduleClass}]."); + } + + return dirname($file); + } +} diff --git a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php index bac8245..a795c21 100644 --- a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php +++ b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php @@ -3,6 +3,7 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Boot\Stages; use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{BootException, ManifestReader, ManifestWriter}; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteParameter; /** Reads routes[] from every module.json -> route-manifest.php (OPcache-cached). */ final class CompileRouteManifestStage implements BootStageContract @@ -32,6 +33,16 @@ public function run(): void { $routes = []; + /** + * Declared route names => the route key that claimed them. Names must be + * unique across the whole application (they are a flat namespace, like + * filter aliases), so a collision is a BOOT failure rather than a + * last-one-wins surprise at URL-generation time. + * + * @var array + */ + $names = []; + // PASS 1 — read every module manifest once and collect the full set of // domains some module solves(). Building this BEFORE compiling any route // means a route's requires[] can name a domain declared by a module that @@ -61,6 +72,8 @@ public function run(): void "Route handler [{$route['handler']}] in [{$moduleClass}] must be in 'Controller@method' format." ); } + $this->validateParameterTypes($route['path'], "Route in [{$moduleClass}]"); + $key = strtoupper($route['method']) . ' ' . $route['path']; if (isset($routes[$key])) { throw new BootException( @@ -71,6 +84,7 @@ public function run(): void 'handler' => $route['handler'], 'module' => $moduleClass, 'solves' => $manifest['solves'], + 'name' => $this->routeName($route, $key, $names, "[{$moduleClass}]"), 'filters' => $this->normalizeFilters($route['filters'] ?? []), 'requires' => $this->validateRequires( $this->normalizeRequires($route['requires'] ?? []), @@ -88,6 +102,15 @@ public function run(): void // without a duplicate-route boot failure. $routes = $this->applyDisablePolicy($routes); + // A disabled plugin route releases its name. Otherwise a project that + // vetoes GET /register and declares its own named 'auth.register' would + // collide with the very route it just removed. + foreach ($names as $name => $owningKey) { + if (!isset($routes[$owningKey])) { + unset($names[$name]); + } + } + // PASS 2b — project-layer routes (Kernel::withRoutes / proj.json), not in // any module.json. They carry no module and resolve under the synthetic // PROJECT_SCOPE, whose dependency graph is empty — so route-level @@ -107,12 +130,29 @@ public function run(): void // plugin route and OVERRIDE a plugin route declaring the same // "METHOD path". This is the default project-over-plugin precedence — // never the reverse. Plugins cannot reclaim a route the project owns. + $this->validateParameterTypes($route['path'], 'Project route'); + $key = strtoupper($route['method']) . ' ' . $route['path']; + // A project override INHERITS the overridden plugin route's name + // unless it declares its own. Overriding changes where a name points, + // not whether it exists — otherwise every route('user.show') in a + // plugin's own views would break the moment a project customised that + // page, which is the single most common thing a project does. + $inherited = $routes[$key]['name'] ?? null; + $declared = $this->routeName($route, $key, $names, 'the project'); + + if ($declared === null && $inherited !== null) { + // Already claimed by the plugin route being replaced — the name + // survives, still pointing at exactly one route. + $declared = $inherited; + } + $routes[$key] = [ 'handler' => $route['handler'], 'module' => null, 'solves' => self::PROJECT_SCOPE, + 'name' => $declared, 'overrides' => $routes[$key]['module'] ?? null, 'filters' => $this->normalizeFilters($route['filters'] ?? []), // Per-route module dependencies seeded into this request's graph @@ -212,6 +252,68 @@ private function applyDisablePolicy(array $routes): array * @param mixed $filters * @return list */ + /** + * Resolve and claim a route's optional `"name"`. + * + * Names are OPTIONAL — an unnamed route is unchanged in every way and simply + * cannot be addressed by UrlGenerator::route(). They live in one flat, + * application-wide namespace, so a duplicate fails the boot: silently letting + * the last declaration win would make route('user.show') resolve to whichever + * plugin happened to load last. + * + * @param array $route + * @param array $names claimed names => owning route key + */ + private function routeName(array $route, string $key, array &$names, string $owner): ?string + { + $name = $route['name'] ?? null; + + if ($name === null || $name === '') { + return null; + } + + if (!is_string($name)) { + throw new BootException("Route [{$key}] in {$owner} has a non-string name."); + } + + if (isset($names[$name])) { + throw new BootException( + "Duplicate route name [{$name}] in {$owner} - already claimed by [{$names[$name]}]. " + . 'Route names are application-wide and must be unique.' + ); + } + + $names[$name] = $key; + + return $name; + } + + /** + * Fail the BOOT on an unknown `{name:type}` placeholder type. + * + * A typo like `{id:number}` would otherwise compile to a route that simply + * never matches — a silent 404 that looks like a missing controller. Same + * anti-typo guard already applied to unknown requires[] domains and to + * disable specs that match nothing. + */ + private function validateParameterTypes(string $path, string $context): void + { + foreach (RouteParameter::parse($path) as $placeholder) { + if ($placeholder['type'] === '' || RouteParameter::isValidType($placeholder['type'])) { + continue; + } + + throw new BootException(sprintf( + '%s declares path [%s] with unknown parameter type [%s] on {%s}. Valid types: %s.', + $context, + $path, + $placeholder['type'], + $placeholder['name'], + implode(', ', RouteParameter::names()), + )); + } + } + private function normalizeFilters(mixed $filters): array { if (is_string($filters)) { diff --git a/src/Kernel/Config/Repository.php b/src/Kernel/Config/Repository.php new file mode 100644 index 0000000..a2197d3 --- /dev/null +++ b/src/Kernel/Config/Repository.php @@ -0,0 +1,112 @@ +get('mail.from.address', 'noreply@example.test'); + * $config->get('database.connections.mysql.host'); + * + * DELIBERATELY IMMUTABLE + * ---------------------- + * There is no set(). HKM 0.3's config repository was mutable and globally + * reachable, which meant any request could reconfigure the process — invisible + * coupling under PHP-FPM, and genuinely unsafe under OpenSwoole where one + * coroutine's write is every other coroutine's surprise. Configuration is + * decided at boot and read thereafter. + * + * If something must change per request, it is request state, not configuration: + * put it on the Request or in the ModuleContainer. + * + * PRECEDENCE + * ---------- + * Compiled project-over-plugin, the same rule routes and views already follow. + * A project's `config/mail.php` overrides a plugin's `config/mail.php` key by + * key (deep merge), so a project overrides only what it names and inherits the + * rest. See the compile stage for the merge semantics. + */ +final class Repository +{ + /** Memoised dotted lookups — the same keys are read repeatedly per request. */ + private array $resolved = []; + + /** @param array $items the compiled config manifest */ + public function __construct(private readonly array $items = []) {} + + /** + * Read a dotted path. Returns $default when any segment is missing. + * + * A null STORED value is a real value and is returned as null — it does not + * fall through to $default. Use has() to distinguish "absent" from "null". + */ + public function get(string $key, mixed $default = null): mixed + { + if (array_key_exists($key, $this->resolved)) { + return $this->resolved[$key]; + } + + $value = $this->lookup($key, $miss); + + if ($miss) { + return $default; // NOT memoised: a later call may pass a different default + } + + return $this->resolved[$key] = $value; + } + + /** True when the dotted path exists, even if its value is null. */ + public function has(string $key): bool + { + $this->lookup($key, $miss); + + return !$miss; + } + + /** + * A whole config group as an array, e.g. all of `mail`. + * + * @return array + */ + public function group(string $name): array + { + $value = $this->get($name, []); + + return is_array($value) ? $value : []; + } + + /** Every compiled config key, for `config:show` and debugging. */ + public function all(): array + { + return $this->items; + } + + /** + * Walk the dotted path. $miss is set by reference so a stored null is + * distinguishable from an absent key. + */ + private function lookup(string $key, ?bool &$miss): mixed + { + $miss = false; + + if (array_key_exists($key, $this->items)) { + return $this->items[$key]; // exact hit — no split needed + } + + $value = $this->items; + foreach (explode('.', $key) as $segment) { + if (!is_array($value) || !array_key_exists($segment, $value)) { + $miss = true; + + return null; + } + $value = $value[$segment]; + } + + return $value; + } +} diff --git a/src/Kernel/Container/CoreContainer.php b/src/Kernel/Container/CoreContainer.php index ab2e8b5..2b542c9 100644 --- a/src/Kernel/Container/CoreContainer.php +++ b/src/Kernel/Container/CoreContainer.php @@ -20,9 +20,12 @@ * * ── Swoole / OpenSwoole safety rules (also enforced under FPM) ─────────────── * - * 1. FROZEN after build — Kernel::build() calls freeze() so any call to - * bind(), singleton(), instance(), or extend() after that point throws a - * LogicException. Request code must NOT mutate this container. + * 1. FROZEN after MATERIALIZE — not after build(). build() is compile-only; + * the kernel calls freeze() at the end of materialize(), which runs on the + * first http()/cli()/workerLoop() call, once every module's boot() has had + * its chance to register. After that, bind(), singleton(), instance() and + * extend() all throw LogicException. Request code must NOT mutate this + * container. * * 2. getInstance() disabled — global container singletons are unsafe under * OpenSwoole (shared across coroutines) and wrong under FPM. Inject the @@ -41,7 +44,7 @@ final class CoreContainer extends Container /** * Bind a pre-built instance directly (used for port adapters and kernel - * services). Must be called before Kernel::build() completes. + * services). Must be called before the kernel materializes. */ public function instance(string $abstract, object $instance): void { @@ -71,8 +74,9 @@ public function extend($abstract, Closure $closure): void /** * Lock the container against further registration. - * Called automatically by Kernel after build() — after this point only - * reads (make / has / get) are permitted. Any write throws LogicException. + * Called automatically by Kernel at the end of materialize() — after this + * point only reads (make / has / get) are permitted. Any write throws + * LogicException. */ public function freeze(): void { diff --git a/src/Kernel/Events/EventBus.php b/src/Kernel/Events/EventBus.php index 7414aaf..ce56a5a 100644 --- a/src/Kernel/Events/EventBus.php +++ b/src/Kernel/Events/EventBus.php @@ -4,9 +4,8 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Events; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\Contracts\{EventListenerContract, IntegrationEventContract}; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; use Psr\Container\ContainerInterface; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; // ─── EventBus ──────────────────────────────────────────────────────────────── @@ -19,8 +18,14 @@ * Listener instances are resolved from the supplied PSR-11 container. Listeners * must be stateless integration handlers (they receive primitive-only events). * - * Subscriber failures are isolated and logged via PSR-3 — one failing listener - * never prevents the others from receiving the event. + * Subscriber failures are isolated and logged through the kernel's LoggerPort — + * one failing listener never prevents the others from receiving the event. + * + * The logger is OPTIONAL (null = no logging). It is deliberately not defaulted to + * a null-object: a swallowed listener exception that is also silently unlogged is + * indistinguishable from an event that was never dispatched, and that is exactly + * the failure this codebase already had when the only LoggerInterface binding + * pointed at a NullLogger. * * IMPORTANT: dispatch ONLY after a successful transaction commit. */ @@ -31,7 +36,7 @@ final class EventBus public function __construct( private readonly ContainerInterface $container, - private readonly LoggerInterface $logger = new NullLogger(), + private readonly ?LoggerPort $logger = null, ) {} /** @@ -55,7 +60,7 @@ public function dispatch(IntegrationEventContract $event): void $listener->handle($event); } catch (\Throwable $e) { // Isolate subscriber failures — never mask the original dispatch. - $this->logger->error('EventBus listener failed', [ + $this->logger?->error('EventBus listener failed', [ 'listener' => $listenerClass, 'event' => $event->name(), 'version' => $event->version(), diff --git a/src/Kernel/Exceptions/HttpStatusAware.php b/src/Kernel/Exceptions/HttpStatusAware.php new file mode 100644 index 0000000..e630282 --- /dev/null +++ b/src/Kernel/Exceptions/HttpStatusAware.php @@ -0,0 +1,39 @@ + 422, GatewayException => 502, + * and so on. Anything else falls to `default => 500`. + * + * That is correct for the kernel's own hierarchy but closed to everyone else: a + * plugin or project exception meaning "this conflicts with existing state" had + * no way to say 409, and surfaced to the client as an opaque 500 — which also + * classifies it as CRITICAL severity and pages someone about an ordinary, + * expected outcome. + * + * Implement this on any exception whose status is part of its meaning: + * + * final class SeatTakenException extends \RuntimeException implements HttpStatusAware + * { + * public function httpStatus(): int { return 409; } + * } + * + * ErrorStage consults this FIRST, before its built-in match, so it also lets a + * project override the status of an exception it does not own by subclassing. + * + * Only 4xx/5xx are honoured. A 2xx or 3xx here would turn an error path into an + * apparent success (or a redirect with no Location), so ErrorStage ignores + * anything outside that range and falls through to its normal mapping. + */ +interface HttpStatusAware +{ + /** The HTTP status for this exception. Must be 4xx or 5xx. */ + public function httpStatus(): int; +} diff --git a/src/Kernel/Exceptions/LockTimeoutException.php b/src/Kernel/Exceptions/LockTimeoutException.php new file mode 100644 index 0000000..4b6738d --- /dev/null +++ b/src/Kernel/Exceptions/LockTimeoutException.php @@ -0,0 +1,24 @@ + $name, 'waited' => $seconds], + ); + } +} diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index ff40871..f9bc4b7 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -4,6 +4,7 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel; use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{BootException, BootPipeline, ManifestReader}; +use AlfacodeTeam\PhpServicePlatform\Kernel\Config\Repository as ConfigRepository; use AlfacodeTeam\PhpServicePlatform\Kernel\Container\CoreContainer; use AlfacodeTeam\PhpServicePlatform\Kernel\Contracts\ModuleContract; use AlfacodeTeam\PhpServicePlatform\Kernel\Error\ErrorPipeline; @@ -12,6 +13,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Cli\CliPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\{WorkerLoop, WorkerPipeline}; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\{SecurityGateway, Contracts\SecurityLayerContract}; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; @@ -21,7 +23,7 @@ * return Kernel::configure() * ->withBasePath(dirname(__DIR__)) * ->withPorts([DatabasePort::class => new MySQLAdapter(config('db'))]) - * ->withSecurity([new FirewallLayer(...), new CsrfTokenLayer(...)]) + * ->withSecurity([new CsrfTokenLayer(...)]) // + your Auth module's layer * ->withErrorPipeline(ErrorPipeline::notifiers([...])->fallback(new FileNotifier(...))) * ->withModules([AuthModule::class, InvoiceModule::class]) * ->build(); @@ -59,6 +61,9 @@ final class Kernel private EventBus $eventBus; private WorkerPipeline $workerPipe; + /** Compiled configuration — lazily read after build(), before materialize(). */ + private ?ConfigRepository $config = null; + private bool $built = false; private bool $materialized = false; /** The surface this kernel was materialized for (null until first entry-point use). */ @@ -337,7 +342,14 @@ private function materialize(RuntimeMode $mode): void $this->mode = $mode; $errorPipeline = $this->errorPipeline ?? ErrorPipeline::default(); - $this->eventBus = new EventBus($this->core); + + // Wire the app logger into the EventBus when the project bound one via + // withPorts(). Without it a listener exception is swallowed AND unlogged, + // which is indistinguishable from the event never being dispatched. + $this->eventBus = new EventBus( + $this->core, + $this->core->has(LoggerPort::class) ? $this->core->get(LoggerPort::class) : null, + ); $this->workerPipe = new WorkerPipeline(); $this->http = new HttpPipeline( @@ -349,6 +361,10 @@ private function materialize(RuntimeMode $mode): void $this->cli = new CliPipeline($this->core, $errorPipeline); $this->workerLoop = new WorkerLoop($this->core, $errorPipeline, $this->workerPipe); + // Configuration compiled by CompileConfigManifestStage during build(). + // Bound BEFORE module boot() so a Provider can read config while wiring. + $this->core->instance(ConfigRepository::class, $this->config()); + // Expose kernel services to modules via the core container. $this->core->instance(EventBus::class, $this->eventBus); $this->core->instance(WorkerPipeline::class, $this->workerPipe); @@ -406,6 +422,23 @@ public function container(): CoreContainer return $this->core; } + /** + * The compiled configuration, read from config-manifest.php. + * + * Available after build() — it does NOT materialize, so bootstrap code and + * tooling can read configuration without standing up the pipelines. The same + * instance is bound into the core container during materialize(), so modules + * resolve it by type-hinting Config\Repository. + */ + public function config(): ConfigRepository + { + $this->ensureBuilt(); + + return $this->config ??= new ConfigRepository( + ManifestReader::readCompiled('config-manifest.php'), + ); + } + /** The surface this kernel materialized for, or null if no entry point ran yet. */ public function mode(): ?RuntimeMode { diff --git a/src/Kernel/Pipelines/Http/RouteMatcher.php b/src/Kernel/Pipelines/Http/RouteMatcher.php index fb8bfba..3a481cb 100644 --- a/src/Kernel/Pipelines/Http/RouteMatcher.php +++ b/src/Kernel/Pipelines/Http/RouteMatcher.php @@ -3,6 +3,8 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteParameter; + /** * RouteMatcher — matches a method+path against the compiled route manifest. * @@ -11,6 +13,15 @@ * and forwarded to the controller. Dynamic routes are bucketed by HTTP method, * so a request only scans the regexes registered for its own method. * + * Placeholders may be TYPED — `{id:num}`, `{slug:slug}`, `{path:any}` — which + * narrows the segment pattern so a non-matching value 404s at the routing layer + * instead of reaching a controller. See {@see RouteParameter} for the type table + * and the compatibility rules. An untyped `{id}` still means `[^/]+`. + * + * Static routes are checked BEFORE dynamic ones, so a literal `/users/me` always + * wins over `/users/{id}` regardless of declaration order. Among dynamic routes + * the first match wins, in manifest order. + * * Built once and reused across requests — it holds no per-request state. */ final class RouteMatcher @@ -39,11 +50,15 @@ public function __construct(array $manifest) $params = []; $regex = preg_replace_callback( - '/\{([^}]+)\}/', + RouteParameter::PLACEHOLDER, static function (array $m) use (&$params): string { $name = preg_replace('/[^a-zA-Z0-9_]/', '', $m[1]); + $type = $m[2] ?? ''; $params[] = $name; - return '(?P<' . $name . '>[^/]+)'; + + // An unknown type already failed the boot in + // CompileRouteManifestStage, so by here it is always valid. + return '(?P<' . $name . '>' . RouteParameter::pattern($type) . ')'; }, $path, ); diff --git a/src/Kernel/Pipelines/Http/Stages/ErrorStage.php b/src/Kernel/Pipelines/Http/Stages/ErrorStage.php index 4ed9dd1..19d2162 100644 --- a/src/Kernel/Pipelines/Http/Stages/ErrorStage.php +++ b/src/Kernel/Pipelines/Http/Stages/ErrorStage.php @@ -7,6 +7,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\{ FrameworkException, GatewayException, + HttpStatusAware, SecurityException, ServiceException, ValidationException @@ -70,6 +71,20 @@ private static function isApiPath(string $path): bool private function resolveHttpCode(\Throwable $e): int { + // An exception that declares its own status wins. Without this, every + // plugin/project exception outside the kernel hierarchy became a 500 — + // opaque to the client AND classified CRITICAL, paging someone about an + // ordinary expected outcome like "that seat is taken". + if ($e instanceof HttpStatusAware) { + $declared = $e->httpStatus(); + + // 2xx/3xx would turn an error path into an apparent success or a + // redirect with no Location. Ignore and fall through. + if ($declared >= 400 && $declared <= 599) { + return $declared; + } + } + if ($e instanceof SecurityException) { $code = $e->getCode(); return in_array($code, [401, 403, 429], true) ? $code : 403; diff --git a/src/Kernel/Pipelines/Worker/WorkerLoop.php b/src/Kernel/Pipelines/Worker/WorkerLoop.php index 926c0d6..0b4cb0a 100644 --- a/src/Kernel/Pipelines/Worker/WorkerLoop.php +++ b/src/Kernel/Pipelines/Worker/WorkerLoop.php @@ -7,6 +7,8 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Error\{ErrorPipeline, ErrorContext}; use AlfacodeTeam\PhpServicePlatform\Kernel\Loading\{DependencyGraphCalculator, OnDemandLoader}; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\Contracts\JobContract; +use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\Retry\{ExponentialRetryStrategy, RetryStrategyContract}; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\QueuePort; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; /** @@ -43,6 +45,8 @@ public function __construct( private readonly ErrorPipeline $errorPipeline, private readonly WorkerPipeline $pipeline, private readonly string $signingSecret = '', + /** Backoff applied by release() in port mode. */ + private readonly RetryStrategyContract $retry = new ExponentialRetryStrategy(), ) { } @@ -52,26 +56,93 @@ public function stop(): void } /** - * Run the loop. $puller returns the next JobPayload or null when idle. + * Run the loop. * - * @param callable():?JobPayload $puller - * @param int $maxIterations 0 = run forever (until stop()). + * Two modes: + * + * 1. PORT MODE (preferred) — pass no $puller. The loop resolves QueuePort + * from the core container and owns the full lifecycle: + * pop → handle → ack / release / fail. Swapping the queue backend then + * needs no code change anywhere. + * + * 2. PULLER MODE (legacy/exotic) — supply a callable returning the next + * JobPayload or null. The CALLER owns ack/retry semantics; the loop only + * executes. Kept for transports that cannot express the port (and for + * tests), but a project should not need it. + * + * @param (callable():?JobPayload)|null $puller null = use the bound QueuePort + * @param int $maxIterations 0 = run forever (until stop()). + * @param string $queue which queue to drain in port mode */ - public function run(callable $puller, int $maxIterations = 0): void + public function run(?callable $puller = null, int $maxIterations = 0, string $queue = 'default'): void { + $port = $puller === null ? $this->queuePort() : null; + + if ($puller === null && $port === null) { + throw new \RuntimeException( + 'WorkerLoop::run() needs either a QueuePort bound in the container ' + . 'or an explicit $puller callable.' + ); + } + $iterations = 0; while (!$this->shouldStop) { if ($maxIterations > 0 && $iterations++ >= $maxIterations) { break; } - $payload = $puller(); + $payload = $port !== null ? $port->pop($queue) : $puller(); if ($payload === null) { usleep(100_000); // idle backoff continue; } + if ($port === null) { + // Puller mode: the caller's driver owns retry/ack. Preserve the + // original contract, including letting a throw propagate. + $this->process($payload); + continue; + } + + $this->processWithPort($port, $payload); + } + } + + /** + * Port mode: run the job and resolve its queue state exactly once. + * + * process() rethrows when a job failed but still has attempts left, and + * returns a result once it has been dead-lettered by its own failed() hook. + * That distinction is what decides release vs fail here. + */ + private function processWithPort(QueuePort $port, JobPayload $payload): void + { + try { $this->process($payload); + + // Completed, skipped, or already dead-lettered by process() — either + // way it must not come back. Removing it is the whole point of ack. + $port->ack($payload); + } catch (\Throwable $e) { + if ($payload->hasExceededMaxAttempts()) { + $port->fail($payload, $e); + + return; + } + + $port->release($payload, $this->retry->delayFor($payload->attempts() + 1)); + } + } + + /** The bound QueuePort, or null when the project wired none. */ + private function queuePort(): ?QueuePort + { + try { + $port = $this->core->has(QueuePort::class) ? $this->core->make(QueuePort::class) : null; + + return $port instanceof QueuePort ? $port : null; + } catch (\Throwable) { + return null; } } diff --git a/src/Kernel/Ports/AbstractLock.php b/src/Kernel/Ports/AbstractLock.php new file mode 100644 index 0000000..7768414 --- /dev/null +++ b/src/Kernel/Ports/AbstractLock.php @@ -0,0 +1,90 @@ +owner; + } + + public function block(int $seconds, ?callable $callback = null): mixed + { + $deadline = \microtime(true) + $seconds; + + while (!$this->acquire()) { + if (\microtime(true) >= $deadline) { + throw LockTimeoutException::for($this->name, $seconds); + } + $this->sleep(self::RETRY_MICROSECONDS); + } + + if ($callback === null) { + return true; + } + + // ALWAYS release — a throwing callback must not strand the lock until TTL. + try { + return $callback(); + } finally { + $this->release(); + } + } + + /** + * Generate an unguessable ownership token. Owner tokens are what make + * release() safe: without one, any process could delete any lock. + */ + protected static function randomOwner(): string + { + return \bin2hex(\random_bytes(16)); + } + + /** + * Coroutine-aware sleep — yields the coroutine under OpenSwoole/Swoole so a + * waiting lock never stalls the whole worker; falls back to usleep() under + * PHP-FPM/CLI. Mirrors the retry backoff in plugins/HttpClient. + */ + protected function sleep(int $micros): void + { + if (\class_exists('\\OpenSwoole\\Coroutine') && \OpenSwoole\Coroutine::getCid() > 0) { + \OpenSwoole\Coroutine::usleep($micros); + return; + } + if (\class_exists('\\Swoole\\Coroutine') && \Swoole\Coroutine::getCid() > 0) { + \Swoole\Coroutine::usleep($micros); + return; + } + \usleep($micros); + } +} diff --git a/src/Kernel/Ports/CachePort.php b/src/Kernel/Ports/CachePort.php index 0e92856..7002bbb 100644 --- a/src/Kernel/Ports/CachePort.php +++ b/src/Kernel/Ports/CachePort.php @@ -11,4 +11,23 @@ public function remember(string $key, int $ttl, callable $callback): mixed; public function increment(string $key, int $by = 1): int; public function deletePattern(string $pattern): int; public function flush(): bool; + + /** + * A mutually-exclusive, TTL-bounded lock. See {@see Lock} for the contract + * and for why increment() is not a substitute. + * + * @param string $name logical lock name (the adapter applies its own prefix) + * @param int $seconds TTL — how long the lock survives if never released. + * 0 means "until released", which risks a permanent + * lock if the holder dies; always prefer a TTL. + * @param string|null $owner explicit owner token; a random one is generated + * when null. Pass one only to reattach deliberately. + */ + public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock; + + /** + * Reattach to a lock acquired elsewhere (another process, an earlier request) + * using its owner token, so THIS caller can release it. Does not acquire. + */ + public function restoreLock(string $name, string $owner): Lock; } diff --git a/src/Kernel/Ports/ClockPort.php b/src/Kernel/Ports/ClockPort.php new file mode 100644 index 0000000..10aad27 --- /dev/null +++ b/src/Kernel/Ports/ClockPort.php @@ -0,0 +1,32 @@ +lock('report:nightly', 300); + * if (!$lock->acquire()) { + * return; // another worker owns it — do nothing + * } + * try { + * $this->generate(); + * } finally { + * $lock->release(); + * } + * + * Or let the lock manage its own lifecycle, blocking up to 10s to acquire: + * + * $result = $cache->lock('report:nightly', 300) + * ->block(10, fn() => $this->generate()); + */ +interface Lock +{ + /** + * Try to acquire ONCE without waiting. + * + * @return bool true when this caller now holds the lock. + */ + public function acquire(): bool; + + /** + * Wait up to $seconds to acquire. + * + * With a callback: runs it while holding the lock, ALWAYS releases + * afterwards (even if the callback throws) and returns the callback's value. + * Without a callback: returns true once acquired — the caller then owns the + * release. + * + * Implementations MUST sleep coroutine-aware (Coroutine::usleep under + * OpenSwoole) so waiting never blocks the whole worker. + * + * @throws LockTimeoutException when the lock could not be acquired in time. + */ + public function block(int $seconds, ?callable $callback = null): mixed; + + /** + * Release, but ONLY if this instance still owns it. + * + * @return bool false when the lock had expired or is held by someone else. + */ + public function release(): bool; + + /** This instance's owner token — pass to CachePort::restoreLock() to reattach. */ + public function owner(): string; + + /** Delete the lock regardless of owner. Administrative recovery only. */ + public function forceRelease(): void; +} diff --git a/src/Kernel/Ports/LogLevel.php b/src/Kernel/Ports/LogLevel.php new file mode 100644 index 0000000..4165720 --- /dev/null +++ b/src/Kernel/Ports/LogLevel.php @@ -0,0 +1,53 @@ + 0, + self::Alert => 1, + self::Critical => 2, + self::Error => 3, + self::Warning => 4, + self::Notice => 5, + self::Info => 6, + self::Debug => 7, + }; + } + + /** True when this level is at least as severe as $minimum. */ + public function passes(self $minimum): bool + { + return $this->severity() <= $minimum->severity(); + } + + /** Parse a level string, falling back to Debug for anything unrecognised. */ + public static function parse(string $level): self + { + return self::tryFrom(strtolower($level)) ?? self::Debug; + } +} diff --git a/src/Kernel/Ports/LoggerPort.php b/src/Kernel/Ports/LoggerPort.php new file mode 100644 index 0000000..3393b6a --- /dev/null +++ b/src/Kernel/Ports/LoggerPort.php @@ -0,0 +1,73 @@ + $context structured data; keys named in the + * message as {placeholder} are interpolated + */ + public function log(string $level, string|\Stringable $message, array $context = []): void; +} diff --git a/src/Kernel/Ports/QueuePort.php b/src/Kernel/Ports/QueuePort.php index de81ed1..c6ad88d 100644 --- a/src/Kernel/Ports/QueuePort.php +++ b/src/Kernel/Ports/QueuePort.php @@ -3,9 +3,39 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Ports; +use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\JobPayload; + /** - * QueuePort — the ONLY way modules enqueue background work. + * QueuePort — the ONLY way modules enqueue AND consume background work. + * * The kernel defines this interface; the project provides the adapter. + * + * WHY THE READ SIDE EXISTS + * ------------------------ + * This port used to be write-only (push/later/size). Consumption happened + * through a `callable $puller` the PROJECT passed to WorkerLoop, which meant the + * abstraction leaked in the worst possible way — the shipped worker template + * contained: + * + * if (!$queueAdapter instanceof FileQueue) { + * return null; // unknown backend — stay idle rather than guess its API + * } + * + * So swapping FileQueue for Redis did not fail: the worker silently processed + * nothing, forever. A port whose consumer must type-check the concrete adapter + * is not a port. pop()/ack()/release()/fail() close that. + * + * THE FOUR-VERB LIFECYCLE + * ----------------------- + * pop reserve the next due job (or null when idle) + * ack it succeeded — remove it permanently + * release it failed but may be retried — return it to the queue after $delay + * fail it exhausted its attempts — move it to the dead-letter store + * + * Every popped job MUST reach exactly one of ack/release/fail. An adapter that + * deletes on pop() loses jobs when a worker dies mid-handle; one that only peeks + * hands the same job to every worker at once. Reserve-then-resolve is the only + * shape that survives a crashing consumer. */ interface QueuePort { @@ -15,5 +45,30 @@ public function push(string $jobClass, array $payload, string $queue = 'default' /** @param array $payload @return string job id */ public function later(int $seconds, string $jobClass, array $payload, string $queue = 'default'): string; + /** Ready + delayed jobs waiting on the queue. */ public function size(string $queue = 'default'): int; + + /** + * Reserve the next due job, or null when the queue is empty. + * + * Must NOT block: WorkerLoop owns the idle backoff, so an adapter that + * blocked here would defeat the loop's stop() and its iteration cap. + */ + public function pop(string $queue = 'default'): ?JobPayload; + + /** The job completed. Remove it permanently. */ + public function ack(JobPayload $payload): void; + + /** + * The job failed but has attempts left. Return it to the queue, available + * again after $delay seconds, with its attempt count incremented. + */ + public function release(JobPayload $payload, int $delay = 0): void; + + /** + * The job exhausted its attempts. Move it out of the queue for inspection — + * never silently drop it, or a permanently-failing job disappears without + * anyone learning why. + */ + public function fail(JobPayload $payload, ?\Throwable $reason = null): void; } diff --git a/src/Kernel/Ports/SystemClock.php b/src/Kernel/Ports/SystemClock.php new file mode 100644 index 0000000..eeca1c4 --- /dev/null +++ b/src/Kernel/Ports/SystemClock.php @@ -0,0 +1,25 @@ + regex fragment. Values are anchored by the matcher. + * + * @var array + */ + public const TYPES = [ + 'num' => '[0-9]+', + 'alpha' => '[a-zA-Z]+', + 'alphanum' => '[a-zA-Z0-9]+', + 'slug' => '[a-zA-Z0-9_-]+', + 'uuid' => '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', + 'segment' => '[^/]+', + 'any' => '.*', + ]; + + /** The pattern used when no type is given — unchanged from before typing existed. */ + public const DEFAULT_PATTERN = '[^/]+'; + + /** Matches one `{name}` or `{name:type}` placeholder. */ + public const PLACEHOLDER = '/\{([^}:]+)(?::([^}]+))?\}/'; + + /** @return list every valid type name, for error messages */ + public static function names(): array + { + return array_keys(self::TYPES); + } + + public static function isValidType(string $type): bool + { + return isset(self::TYPES[$type]); + } + + /** + * The regex fragment for a type. An empty type means "untyped". + * + * @throws \InvalidArgumentException on an unknown type — callers that need a + * boot-time failure (the compiler) catch and rethrow as BootException. + */ + public static function pattern(string $type): string + { + if ($type === '') { + return self::DEFAULT_PATTERN; + } + + if (!self::isValidType($type)) { + throw new \InvalidArgumentException( + "Unknown route parameter type [{$type}]. Valid types: " . implode(', ', self::names()) . '.' + ); + } + + return self::TYPES[$type]; + } + + /** + * Every placeholder in a path, as [name, type] pairs (type '' when untyped). + * + * @return list + */ + public static function parse(string $path): array + { + if (!str_contains($path, '{')) { + return []; + } + + preg_match_all(self::PLACEHOLDER, $path, $matches, PREG_SET_ORDER); + + $found = []; + foreach ($matches as $match) { + $found[] = [ + 'name' => $match[1], + 'type' => $match[2] ?? '', + ]; + } + + return $found; + } +} diff --git a/src/Kernel/Routing/UrlGenerator.php b/src/Kernel/Routing/UrlGenerator.php new file mode 100644 index 0000000..55490a6 --- /dev/null +++ b/src/Kernel/Routing/UrlGenerator.php @@ -0,0 +1,280 @@ +route('auth.register'); // /register — or wherever it moved + * $url->route('user.show', ['id' => 7]); // /users/7 + * + * PARAMETERS ARE VALIDATED AGAINST THEIR TYPE + * ------------------------------------------- + * A route declared `/users/{id:num}` will not generate `/users/abc`. Producing a + * URL the matcher provably cannot match is always a bug, and catching it here + * turns a mystery 404 into an exception at the call site. + * + * SIGNED URLS + * ----------- + * signedRoute() appends an HMAC over the URL so a recipient cannot tamper with + * it — the standard mechanism behind email verification and one-time action + * links. Signing uses HMAC (integrity), not encryption (confidentiality): the + * parameters stay readable, they just cannot be changed. + * + * NOT REQUEST-SCOPED + * ------------------ + * This class holds no request state. Absolute URLs need a base, which the caller + * supplies (typically from `$request->site()`), so the generator stays usable + * from CLI and worker contexts where there is no Request at all — which is + * exactly where email links get built. + */ +final class UrlGenerator +{ + /** @var array route name => path template */ + private array $byName = []; + + /** @var array route name => HTTP method */ + private array $methodByName = []; + + /** + * @param array> $manifest compiled route manifest + * @param string $base base URL for absolute generation, e.g. https://app.example.com + * @param string $secret HMAC key for signed URLs; defaults to APP_KEY + */ + public function __construct( + array $manifest, + private readonly string $base = '', + private readonly string $secret = '', + ) { + foreach ($manifest as $key => $entry) { + $name = $entry['name'] ?? null; + if (!is_string($name) || $name === '') { + continue; + } + [$method, $path] = explode(' ', $key, 2); + $this->byName[$name] = $path; + $this->methodByName[$name] = $method; + } + } + + /** Build from the compiled manifest on disk. */ + public static function fromManifest(string $base = '', string $secret = ''): self + { + return new self( + ManifestReader::readCompiled('route-manifest.php'), + $base, + $secret !== '' ? $secret : (string) (\function_exists('env') ? (env('APP_KEY') ?: '') : ''), + ); + } + + public function has(string $name): bool + { + return isset($this->byName[$name]); + } + + /** The HTTP method a named route answers — useful for building forms. */ + public function methodFor(string $name): ?string + { + return $this->methodByName[$name] ?? null; + } + + /** + * The URL for a named route. + * + * Parameters not consumed by a path placeholder become the query string, so + * `route('search', ['q' => 'x'])` on `/search` yields `/search?q=x`. + * + * @param array $parameters + * + * @throws \InvalidArgumentException on an unknown name, a missing required + * parameter, or a value that violates the placeholder's declared type + */ + public function route(string $name, array $parameters = [], bool $absolute = false): string + { + $template = $this->byName[$name] ?? null; + + if ($template === null) { + throw new \InvalidArgumentException( + "Unknown route name [{$name}]. Declare \"name\" on the route in module.json or proj.json." + ); + } + + $path = $this->substitute($name, $template, $parameters, $remaining); + + if ($remaining !== []) { + $path .= '?' . http_build_query($remaining); + } + + return $absolute ? $this->absolute($path) : $path; + } + + /** + * A URL for a literal path — the escape hatch for endpoints that have no name + * (a plugin's route you did not author, an external redirect target). + * + * @param array $query + */ + public function to(string $path, array $query = [], bool $absolute = false): string + { + $path = '/' . ltrim($path, '/'); + + if ($query !== []) { + $path .= '?' . http_build_query($query); + } + + return $absolute ? $this->absolute($path) : $path; + } + + /** + * A tamper-proof URL for a named route. + * + * Appends `signature`, an HMAC over the path and its query. Any change to the + * path or to a parameter invalidates it. Optionally appends `expires` (a UNIX + * timestamp) which is covered by the same signature, so the deadline cannot be + * extended by editing the URL. + * + * @param array $parameters + * @param int|null $expiresIn seconds from now; null = no expiry + * + * @throws \RuntimeException when no signing secret is configured — failing + * closed, because a URL signed with an empty key is forgeable by + * anyone and would look identical to a real one + */ + public function signedRoute( + string $name, + array $parameters = [], + ?int $expiresIn = null, + bool $absolute = false, + ): string { + $this->requireSecret(); + + if ($expiresIn !== null) { + $parameters['expires'] = time() + $expiresIn; + } + + $url = $this->route($name, $parameters); + + return $absolute + ? $this->absolute($this->appendSignature($url)) + : $this->appendSignature($url); + } + + /** + * Verify a signed URL: signature intact AND (if present) not expired. + * + * Accepts a path with query string, e.g. `/verify/7?expires=…&signature=…`. + * Pass the path only — a host is not covered by the signature, so including + * one would make verification fail behind a proxy that rewrites it. + */ + public function hasValidSignature(string $url): bool + { + if ($this->secret === '') { + return false; // fail closed — never validate against an empty key + } + + [$path, $query] = array_pad(explode('?', $url, 2), 2, ''); + + parse_str($query, $params); + + $signature = $params['signature'] ?? null; + unset($params['signature']); + + if (!is_string($signature) || $signature === '') { + return false; + } + + if (isset($params['expires']) && (int) $params['expires'] < time()) { + return false; + } + + $expected = $this->sign($path . ($params === [] ? '' : '?' . http_build_query($params))); + + // hash_equals — a timing-safe comparison. Never ===. + return hash_equals($expected, $signature); + } + + // ── internals ──────────────────────────────────────────────────────────── + + /** + * Replace `{name}` / `{name:type}` with values, validating each against its + * declared type. Unconsumed parameters are returned via $remaining. + * + * @param array $parameters + * @param array|null $remaining + */ + private function substitute(string $name, string $template, array $parameters, ?array &$remaining): string + { + $remaining = $parameters; + + $path = preg_replace_callback( + RouteParameter::PLACEHOLDER, + function (array $m) use ($name, &$remaining): string { + $param = $m[1]; + $type = $m[2] ?? ''; + + if (!array_key_exists($param, $remaining)) { + throw new \InvalidArgumentException( + "Route [{$name}] needs a value for {{$param}}." + ); + } + + $value = (string) $remaining[$param]; + unset($remaining[$param]); + + // Generating a URL the matcher cannot match is always a bug. + $pattern = RouteParameter::pattern($type); + if (preg_match('#^' . $pattern . '$#', $value) !== 1) { + throw new \InvalidArgumentException( + "Value [{$value}] for {{$param}} on route [{$name}] does not satisfy type" + . ($type === '' ? ' (a single path segment)' : " [{$type}]") . '.' + ); + } + + return rawurlencode($value); + }, + $template, + ); + + return (string) $path; + } + + private function absolute(string $path): string + { + return rtrim($this->base, '/') . $path; + } + + private function appendSignature(string $url): string + { + return $url . (str_contains($url, '?') ? '&' : '?') . 'signature=' . $this->sign($url); + } + + private function sign(string $url): string + { + return hash_hmac('sha256', $url, $this->secret); + } + + private function requireSecret(): void + { + if ($this->secret === '') { + throw new \RuntimeException( + 'Cannot sign a URL: no signing secret configured (APP_KEY is empty). ' + . 'A URL signed with an empty key is forgeable by anyone.' + ); + } + } +} diff --git a/src/Kernel/Security/Layers/CsrfTokenLayer.php b/src/Kernel/Security/Layers/CsrfTokenLayer.php index 9da24d8..442df99 100644 --- a/src/Kernel/Security/Layers/CsrfTokenLayer.php +++ b/src/Kernel/Security/Layers/CsrfTokenLayer.php @@ -4,6 +4,8 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\ClockPort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SystemClock; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Contracts\SecurityLayerContract; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\SecurityVerdict; @@ -61,6 +63,13 @@ public function __construct( private readonly int $lifetime = 43200, private readonly array $exemptPaths = [], private readonly array $exemptMethods = [], + /** + * Source of "now". Defaults to the real clock, so behaviour is unchanged. + * Tests inject a frozen clock to exercise token expiry without sleeping + * past a 12-hour lifetime — which is why this expiry logic was + * effectively untestable before. + */ + private readonly ?ClockPort $clock = null, ) { $this->secret = $secret ?? (string) (env('APP_KEY') ?: ''); } @@ -113,7 +122,7 @@ public function check(Request $request): SecurityVerdict */ public function issue(Request $request, string $action = ''): string { - return self::make($this->secret, $this->binding($request), $this->lifetime, $action); + return self::make($this->secret, $this->binding($request), $this->lifetime, $action, $this->clock); } // ─── public static API (for controllers / views that mint & check tokens) ── @@ -124,9 +133,9 @@ public function issue(Request $request, string $action = ''): string * * $token = CsrfTokenLayer::make(env('APP_KEY'), $sessionCookieValue); */ - public static function make(string $secret, string $binding = '', int $lifetime = 43200, string $action = ''): string + public static function make(string $secret, string $binding = '', int $lifetime = 43200, string $action = '', ?ClockPort $clock = null): string { - return self::build($secret, self::tickFor($lifetime), $binding, $action); + return self::build($secret, self::tickFor($lifetime, $clock), $binding, $action); } /** @@ -134,7 +143,7 @@ public static function make(string $secret, string $binding = '', int $lifetime * already runs check() for every unsafe request, so this is only for code * that wants to validate a token itself without denying the request. */ - public static function valid(string $secret, string $token, string $binding = '', int $lifetime = 43200): bool + public static function valid(string $secret, string $token, string $binding = '', int $lifetime = 43200, ?ClockPort $clock = null): bool { if ($secret === '' || $token === '') { return false; @@ -150,7 +159,7 @@ public static function valid(string $secret, string $token, string $binding = '' return false; } - $now = self::tickFor($lifetime); + $now = self::tickFor($lifetime, $clock); // Accept this tick or the immediately previous one; reject anything else // (expired, or a future tick that should not yet exist). if ($tick !== $now && $tick !== $now - 1) { @@ -168,7 +177,7 @@ public static function valid(string $secret, string $token, string $binding = '' private function verify(string $token, string $binding): bool { - return self::valid($this->secret, $token, $binding, $this->lifetime); + return self::valid($this->secret, $token, $binding, $this->lifetime, $this->clock); } /** token = tick . "." . hex(HMAC(secret, tick|binding|action)). Action rides after the sig for re-derivation. */ @@ -193,11 +202,12 @@ private static function actionFrom(string $token): string } /** WordPress-style half-life tick: two overlapping windows per lifetime. */ - private static function tickFor(int $lifetime): int + private static function tickFor(int $lifetime, ?ClockPort $clock = null): int { $half = max(1, intdiv($lifetime, 2)); + $now = ($clock ?? new SystemClock())->timestamp(); - return (int) ceil(time() / $half); + return (int) ceil($now / $half); } /** diff --git a/src/Kernel/Security/SecurityGateway.php b/src/Kernel/Security/SecurityGateway.php index b2a44f5..508c9d5 100644 --- a/src/Kernel/Security/SecurityGateway.php +++ b/src/Kernel/Security/SecurityGateway.php @@ -13,11 +13,14 @@ * Runs BEFORE any module loads into memory. * Denied requests never touch module code — zero module cost. * - * Layer order matters: cheapest first. - * 1. FirewallLayer (IP blocklist — nanoseconds) - * 2. RateLimiterLayer (cache counter — microseconds) - * 3. CsrfTokenLayer (timing-safe string compare — microseconds) - * 4. [Auth module layer] (token verify — milliseconds, optional) + * Layer order matters: cheapest first. A typical stack: + * 1. CsrfTokenLayer (timing-safe string compare — microseconds) + * 2. [Auth module layer] (token verify — milliseconds, optional) + * + * The kernel ships exactly ONE layer: CsrfTokenLayer. IP filtering and rate + * limiting are deliberately NOT kernel layers — they are opt-in route filters + * from plugins/SecurityFilters ('throttle', 'shield'), so a route pays for them + * only when it declares them. Do not reintroduce them here. */ final class SecurityGateway { diff --git a/src/Kernel/Support/helpers.php b/src/Kernel/Support/helpers.php index e262c7b..03385da 100644 --- a/src/Kernel/Support/helpers.php +++ b/src/Kernel/Support/helpers.php @@ -1,6 +1,8 @@ get($key, $default); + } +} + if (!function_exists('collect')) { /** * Create a Collection from the given items. diff --git a/tests/Unit/Kernel/Boot/RouteNameCompilationTest.php b/tests/Unit/Kernel/Boot/RouteNameCompilationTest.php new file mode 100644 index 0000000..13d2777 --- /dev/null +++ b/tests/Unit/Kernel/Boot/RouteNameCompilationTest.php @@ -0,0 +1,173 @@ +root = sys_get_temp_dir() . '/hkm-routename-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root); + } + + /** + * Build a stub plugin whose module.json declares $routes, so the compiler + * exercises its real plugin path rather than a project-only shortcut. + * + * @param list> $routes + * @return class-string + */ + private function plugin(array $routes, string $solves = 'demo.domain'): string + { + $dir = $this->root . '/plugin' . bin2hex(random_bytes(4)); + mkdir($dir, 0775, true); + + file_put_contents($dir . '/module.json', json_encode([ + 'name' => 'demo', + 'solves' => $solves, + 'routes' => $routes, + ])); + + $class = 'StubProvider' . bin2hex(random_bytes(4)); + file_put_contents($dir . '/Provider.php', " $modules + * @param list> $projectRoutes + * @param list $disabled + * @return array> + */ + private function compile(array $modules, array $projectRoutes = [], array $disabled = []): array + { + (new CompileRouteManifestStage( + $modules, + projectRoutes: $projectRoutes, + disabledRoutes: $disabled, + reader: new ManifestReader(), + ))->run(); + + return ManifestReader::readCompiled('route-manifest.php'); + } + + public function test_a_declared_name_is_compiled_onto_the_route(): void + { + $manifest = $this->compile([ + $this->plugin([ + ['method' => 'GET', 'path' => '/register', 'handler' => 'P\\C@show', 'name' => 'auth.register'], + ]), + ]); + + self::assertSame('auth.register', $manifest['GET /register']['name']); + } + + public function test_a_route_without_a_name_compiles_with_null(): void + { + $manifest = $this->compile([ + $this->plugin([['method' => 'GET', 'path' => '/health', 'handler' => 'P\\C@show']]), + ]); + + self::assertNull($manifest['GET /health']['name']); + } + + public function test_duplicate_names_fail_the_boot(): void + { + // Last-one-wins would make route('auth.register') resolve to whichever + // plugin happened to load last — a silent, ordering-dependent bug. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/Duplicate route name \[auth\.register\]/'); + + $this->compile([ + $this->plugin([ + ['method' => 'GET', 'path' => '/register', 'handler' => 'P\\C@a', 'name' => 'auth.register'], + ['method' => 'POST', 'path' => '/register2', 'handler' => 'P\\C@b', 'name' => 'auth.register'], + ]), + ]); + } + + public function test_a_project_override_inherits_the_plugin_route_name(): void + { + // THE point of names: the plugin's own links keep resolving after the + // project takes over the page. + $manifest = $this->compile( + [$this->plugin([ + ['method' => 'GET', 'path' => '/register', 'handler' => 'P\\C@show', 'name' => 'auth.register'], + ])], + projectRoutes: [ + ['method' => 'GET', 'path' => '/register', 'handler' => 'App\\C@show'], + ], + ); + + self::assertSame('App\\C@show', $manifest['GET /register']['handler'], 'project wins'); + self::assertSame('auth.register', $manifest['GET /register']['name'], 'name survives'); + } + + public function test_a_project_override_may_declare_its_own_name(): void + { + $manifest = $this->compile( + [$this->plugin([ + ['method' => 'GET', 'path' => '/register', 'handler' => 'P\\C@show', 'name' => 'auth.register'], + ])], + projectRoutes: [ + ['method' => 'GET', 'path' => '/register', 'handler' => 'App\\C@show', 'name' => 'signup'], + ], + ); + + self::assertSame('signup', $manifest['GET /register']['name']); + } + + public function test_disabling_a_route_releases_its_name_for_reuse(): void + { + // Without the release, a project that vetoes GET /register and declares + // its own 'auth.register' would collide with the route it just removed. + $manifest = $this->compile( + [$this->plugin([ + ['method' => 'GET', 'path' => '/register', 'handler' => 'P\\C@show', 'name' => 'auth.register'], + ])], + projectRoutes: [ + ['method' => 'GET', 'path' => '/signup', 'handler' => 'App\\C@show', 'name' => 'auth.register'], + ], + disabled: ['GET /register'], + ); + + self::assertArrayNotHasKey('GET /register', $manifest); + self::assertSame('auth.register', $manifest['GET /signup']['name']); + } +} diff --git a/tests/Unit/Kernel/Boot/RouteParameterValidationTest.php b/tests/Unit/Kernel/Boot/RouteParameterValidationTest.php new file mode 100644 index 0000000..7c1f879 --- /dev/null +++ b/tests/Unit/Kernel/Boot/RouteParameterValidationTest.php @@ -0,0 +1,101 @@ +root = sys_get_temp_dir() . '/hkm-routeparam-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root); + } + + /** @param list> $projectRoutes */ + private function compile(array $projectRoutes): void + { + (new CompileRouteManifestStage( + [], + projectRoutes: $projectRoutes, + reader: new ManifestReader(), + ))->run(); + } + + public function test_a_valid_typed_route_compiles(): void + { + $this->compile([ + ['method' => 'GET', 'path' => '/users/{id:num}', 'handler' => 'App\\C@show'], + ]); + + $manifest = ManifestReader::readCompiled('route-manifest.php'); + self::assertArrayHasKey('GET /users/{id:num}', $manifest); + } + + public function test_an_untyped_route_still_compiles_unchanged(): void + { + $this->compile([ + ['method' => 'GET', 'path' => '/users/{id}', 'handler' => 'App\\C@show'], + ]); + + self::assertArrayHasKey('GET /users/{id}', ManifestReader::readCompiled('route-manifest.php')); + } + + public function test_an_unknown_type_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/unknown parameter type \[number\]/'); + + $this->compile([ + ['method' => 'GET', 'path' => '/users/{id:number}', 'handler' => 'App\\C@show'], + ]); + } + + public function test_the_error_names_the_placeholder_and_lists_valid_types(): void + { + try { + $this->compile([ + ['method' => 'GET', 'path' => '/p/{slug:txt}', 'handler' => 'App\\C@show'], + ]); + self::fail('expected a BootException'); + } catch (BootException $e) { + self::assertStringContainsString('{slug}', $e->getMessage()); + self::assertStringContainsString('num', $e->getMessage(), 'valid types are listed'); + } + } +} diff --git a/tests/Unit/Kernel/Config/CompileConfigManifestStageTest.php b/tests/Unit/Kernel/Config/CompileConfigManifestStageTest.php new file mode 100644 index 0000000..f3b0d3a --- /dev/null +++ b/tests/Unit/Kernel/Config/CompileConfigManifestStageTest.php @@ -0,0 +1,125 @@ +root = sys_get_temp_dir() . '/hkm-config-test-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/plugin/config', 0775, true); + mkdir($this->root . '/project/config', 0775, true); + mkdir($this->root . '/project/var/cache/manifests', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root . '/project'); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + $this->rmrf($this->root); + } + + private function rmrf(string $dir): void + { + if (!is_dir($dir)) { + return; + } + /** @var \SplFileInfo $file */ + foreach (new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ) as $file) { + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + @rmdir($dir); + } + + /** @param array $plugin @param array $project */ + private function compile(array $plugin, array $project): Repository + { + file_put_contents( + $this->root . '/plugin/config/demo.php', + 'root . '/project/config/demo.php', + 'root . '/plugin/Provider.php'; + $class = 'TestConfigProvider' . bin2hex(random_bytes(4)); + file_put_contents($providerFile, "run(); + + return new Repository(ManifestReader::readCompiled('config-manifest.php')); + } + + public function test_project_overrides_only_the_key_it_names(): void + { + $config = $this->compile( + plugin: ['from' => ['address' => 'plugin@test', 'name' => 'Plugin'], 'timeout' => 30], + project: ['from' => ['address' => 'project@test']], + ); + + self::assertSame('project@test', $config->get('demo.from.address'), 'project wins'); + self::assertSame('Plugin', $config->get('demo.from.name'), 'unnamed sibling key is inherited'); + self::assertSame(30, $config->get('demo.timeout'), 'unnamed sibling group is inherited'); + } + + public function test_a_project_list_replaces_rather_than_appends(): void + { + $config = $this->compile( + plugin: ['transports' => ['smtp', 'sendmail', 'log']], + project: ['transports' => ['smtp']], + ); + + // Appending would make it impossible to REMOVE a shipped default, which + // is usually the entire reason for overriding a list. + self::assertSame(['smtp'], $config->get('demo.transports')); + } + + public function test_plugin_config_survives_with_no_project_override(): void + { + $config = $this->compile(plugin: ['driver' => 'local'], project: []); + + self::assertSame('local', $config->get('demo.driver')); + } + + public function test_a_config_file_returning_a_non_array_fails_the_boot(): void + { + file_put_contents($this->root . '/project/config/broken.php', 'expectExceptionMessageMatches('/must return an array, got string/'); + + (new CompileConfigManifestStage([], new ManifestReader()))->run(); + } +} diff --git a/tests/Unit/Kernel/Config/ConfigCommandsTest.php b/tests/Unit/Kernel/Config/ConfigCommandsTest.php new file mode 100644 index 0000000..8df963f --- /dev/null +++ b/tests/Unit/Kernel/Config/ConfigCommandsTest.php @@ -0,0 +1,127 @@ +root = sys_get_temp_dir() . '/hkm-config-cmd-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + + ManifestWriter::write('config-manifest.php', [ + 'mail' => [ + 'from' => ['address' => 'noreply@example.test'], + 'transport' => 'smtp', + 'debug' => false, + ], + ]); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root . '/var'); + @rmdir($this->root); + } + + /** @param list $argv */ + private function exec(AbstractCommand $command, array $argv, BufferIO $io): int + { + return $command->execute($argv, $io); + } + + /** + * NOTE: the table renderer writes straight to STDOUT rather than through the + * injected IO, so BufferIO cannot observe it. Assertions therefore go through + * --json (which writes via info()) or the scalar path — both exercise the + * command's own logic, which is what these tests are about. + */ + public function test_show_lists_groups_by_default(): void + { + $io = new BufferIO(); + + self::assertSame(AbstractCommand::SUCCESS, $this->exec(new ConfigShowCommand(), ['--json'], $io)); + self::assertStringContainsString('"mail"', $io->getOutput()); + } + + public function test_show_resolves_a_dotted_key(): void + { + $io = new BufferIO(); + + $this->exec(new ConfigShowCommand(), ['mail.from.address'], $io); + + self::assertStringContainsString('noreply@example.test', $io->getOutput()); + } + + public function test_show_renders_a_whole_group(): void + { + $io = new BufferIO(); + + $this->exec(new ConfigShowCommand(), ['mail', '--json'], $io); + $out = $io->getOutput(); + + self::assertStringContainsString('"transport"', $out); + self::assertStringContainsString('noreply@example.test', $out); + } + + public function test_a_false_value_renders_as_false_not_an_empty_string(): void + { + $io = new BufferIO(); + + // PHP casts false to '' — printing that would read as "not configured". + $this->exec(new ConfigShowCommand(), ['mail.debug'], $io); + + self::assertStringContainsString('false', $io->getOutput()); + } + + public function test_show_fails_on_an_unknown_key(): void + { + $io = new BufferIO(); + + self::assertSame( + AbstractCommand::FAILURE, + $this->exec(new ConfigShowCommand(), ['mail.nope'], $io), + ); + } + + public function test_clear_deletes_the_manifest_then_reports_nothing_to_do(): void + { + $path = Paths::cache('manifests/config-manifest.php'); + self::assertFileExists($path); + + self::assertSame(AbstractCommand::SUCCESS, $this->exec(new ConfigClearCommand(), [], new BufferIO())); + self::assertFileDoesNotExist($path); + + // Idempotent — clearing twice is not an error. + $io = new BufferIO(); + self::assertSame(AbstractCommand::SUCCESS, $this->exec(new ConfigClearCommand(), [], $io)); + self::assertStringContainsString('nothing to clear', $io->getOutput()); + } +} diff --git a/tests/Unit/Kernel/Config/RepositoryTest.php b/tests/Unit/Kernel/Config/RepositoryTest.php new file mode 100644 index 0000000..6be43b1 --- /dev/null +++ b/tests/Unit/Kernel/Config/RepositoryTest.php @@ -0,0 +1,94 @@ + [ + 'from' => ['address' => 'noreply@example.test', 'name' => 'Example'], + 'transport' => 'smtp', + 'reply_to' => null, + 'transports' => ['smtp', 'sendmail'], + ], + 'validation' => ['rulesets' => []], + ]); + } + + public function test_reads_a_dotted_path(): void + { + self::assertSame('noreply@example.test', $this->repository()->get('mail.from.address')); + } + + public function test_reads_a_top_level_group(): void + { + self::assertSame(['rulesets' => []], $this->repository()->get('validation')); + } + + public function test_missing_key_returns_the_default(): void + { + self::assertSame('fallback', $this->repository()->get('mail.from.title', 'fallback')); + self::assertSame('fallback', $this->repository()->get('nope.at.all', 'fallback')); + } + + public function test_descending_into_a_scalar_returns_the_default(): void + { + // 'mail.transport' is a string; asking for a child of it is a miss, not a crash. + self::assertSame('d', $this->repository()->get('mail.transport.host', 'd')); + } + + public function test_a_stored_null_is_returned_not_the_default(): void + { + // The distinction matters: "configured to nothing" is not "not configured". + self::assertNull($this->repository()->get('mail.reply_to', 'DEFAULT')); + } + + public function test_has_distinguishes_absent_from_null(): void + { + $config = $this->repository(); + + self::assertTrue($config->has('mail.reply_to'), 'a stored null still exists'); + self::assertFalse($config->has('mail.missing')); + } + + public function test_group_always_returns_an_array(): void + { + $config = $this->repository(); + + self::assertSame(['smtp', 'sendmail'], $config->group('mail')['transports']); + self::assertSame([], $config->group('does-not-exist')); + } + + public function test_memoisation_does_not_leak_a_default_into_later_reads(): void + { + $config = $this->repository(); + + // A miss must NOT be cached — otherwise the first caller's default would + // be served to every later caller asking for the same key. + self::assertSame('first', $config->get('mail.absent', 'first')); + self::assertSame('second', $config->get('mail.absent', 'second')); + } + + public function test_all_exposes_the_whole_manifest(): void + { + self::assertArrayHasKey('mail', $this->repository()->all()); + } + + public function test_an_empty_repository_is_safe_to_read(): void + { + $config = new Repository(); + + self::assertNull($config->get('anything')); + self::assertFalse($config->has('anything')); + self::assertSame([], $config->all()); + } +} diff --git a/tests/Unit/Kernel/Exceptions/HttpStatusAwareTest.php b/tests/Unit/Kernel/Exceptions/HttpStatusAwareTest.php new file mode 100644 index 0000000..0dab9b8 --- /dev/null +++ b/tests/Unit/Kernel/Exceptions/HttpStatusAwareTest.php @@ -0,0 +1,81 @@ +handle($request, static function () use ($e): Response { + throw $e; + }); + + return $response->getStatusCode(); + } + + public function test_an_exception_declaring_its_status_gets_it(): void + { + // Before this, ANY exception outside the kernel hierarchy became a 500 — + // opaque to the client and classified CRITICAL, paging someone about an + // ordinary expected outcome. + $e = new class('seat taken') extends \RuntimeException implements HttpStatusAware { + public function httpStatus(): int { return 409; } + }; + + self::assertSame(409, $this->statusFor($e)); + } + + public function test_a_plain_exception_is_still_a_500(): void + { + self::assertSame(500, $this->statusFor(new \RuntimeException('boom'))); + } + + public function test_the_kernel_hierarchy_is_unchanged(): void + { + self::assertSame(422, $this->statusFor(new ValidationException(['email' => 'Required.']))); + self::assertSame(502, $this->statusFor(new GatewayException('upstream down'))); + } + + public function test_a_declared_status_wins_over_the_built_in_mapping(): void + { + // Lets a project correct the status of an exception type it does not own, + // by subclassing rather than editing the kernel. + $e = new class('too big') extends ValidationException implements HttpStatusAware { + public function __construct(string $message) { parent::__construct([], $message); } + public function httpStatus(): int { return 413; } + }; + + self::assertSame(413, $this->statusFor($e)); + } + + public function test_a_nonsense_status_is_ignored(): void + { + // 200 would turn an error path into an apparent success; 302 would be a + // redirect with no Location. Fall through to the normal mapping instead. + $ok = new class('weird') extends \RuntimeException implements HttpStatusAware { + public function httpStatus(): int { return 200; } + }; + $redirect = new class('weird') extends \RuntimeException implements HttpStatusAware { + public function httpStatus(): int { return 302; } + }; + + self::assertSame(500, $this->statusFor($ok)); + self::assertSame(500, $this->statusFor($redirect)); + } +} diff --git a/tests/Unit/Kernel/Pipelines/Http/RouteMatcherTest.php b/tests/Unit/Kernel/Pipelines/Http/RouteMatcherTest.php new file mode 100644 index 0000000..30b8950 --- /dev/null +++ b/tests/Unit/Kernel/Pipelines/Http/RouteMatcherTest.php @@ -0,0 +1,155 @@ +> $manifest */ + private function matcher(array $manifest): RouteMatcher + { + return new RouteMatcher($manifest); + } + + /** @param list $paths */ + private function manifest(array $paths, string $method = 'GET'): array + { + $manifest = []; + foreach ($paths as $path) { + $manifest[$method . ' ' . $path] = ['handler' => 'C@m', 'solves' => 'x']; + } + + return $manifest; + } + + // ── Backward compatibility ────────────────────────────────────────────── + + public function test_a_static_route_matches_exactly(): void + { + $m = $this->matcher($this->manifest(['/health'])); + + self::assertNotNull($m->match('GET', '/health')); + self::assertNull($m->match('GET', '/healthz')); + } + + public function test_an_untyped_placeholder_still_means_one_segment(): void + { + $m = $this->matcher($this->manifest(['/users/{id}'])); + + // Unchanged from before typing existed — this must never regress. + self::assertSame(['id' => 'abc'], $m->match('GET', '/users/abc')['params']); + self::assertNull($m->match('GET', '/users/a/b'), 'must not cross a slash'); + } + + public function test_a_static_route_wins_over_a_dynamic_one(): void + { + // Declaration order puts the dynamic route first on purpose. + $m = $this->matcher($this->manifest(['/users/{id}', '/users/me'])); + + self::assertSame([], $m->match('GET', '/users/me')['params'], 'static must win'); + } + + public function test_a_route_is_scoped_to_its_method(): void + { + $m = $this->matcher($this->manifest(['/users/{id}'], 'POST')); + + self::assertNull($m->match('GET', '/users/1')); + self::assertNotNull($m->match('POST', '/users/1')); + } + + // ── Typed parameters — the regression this phase closes ───────────────── + + public function test_num_rejects_a_non_numeric_id(): void + { + $m = $this->matcher($this->manifest(['/users/{id:num}'])); + + self::assertNotNull($m->match('GET', '/users/42')); + // This is the whole point: previously /users/abc matched and the check + // silently moved into the controller. + self::assertNull($m->match('GET', '/users/abc')); + } + + /** @return array */ + public static function typedCases(): array + { + return [ + 'num accepts digits' => ['num', '2026', true], + 'num rejects letters' => ['num', 'x1', false], + 'alpha accepts letters' => ['alpha', 'draft', true], + 'alpha rejects digits' => ['alpha', 'draft2', false], + 'alphanum accepts mixed' => ['alphanum', 'a1b2', true], + 'alphanum rejects dash' => ['alphanum', 'a-1', false], + 'slug accepts dashes' => ['slug', 'my-post_1', true], + 'slug rejects a dot' => ['slug', 'my.post', false], + 'uuid accepts a uuid' => ['uuid', '3f2504e0-4f89-11d3-9a0c-0305e82c3301', true], + 'uuid rejects a short string' => ['uuid', 'not-a-uuid', false], + 'segment accepts anything' => ['segment', 'any.thing-here', true], + ]; + } + + #[DataProvider('typedCases')] + public function test_typed_parameters_constrain_the_segment(string $type, string $value, bool $expected): void + { + $m = $this->matcher($this->manifest(['/r/{p:' . $type . '}'])); + + self::assertSame($expected, $m->match('GET', '/r/' . $value) !== null); + } + + public function test_any_is_a_catch_all_that_crosses_slashes(): void + { + // 0.3's (:any). No equivalent existed before this phase. + $m = $this->matcher($this->manifest(['/files/{path:any}'])); + + $match = $m->match('GET', '/files/a/b/c.txt'); + self::assertNotNull($match); + self::assertSame('a/b/c.txt', $match['params']['path']); + } + + public function test_several_typed_parameters_in_one_path(): void + { + $m = $this->matcher($this->manifest(['/posts/{year:num}/{slug:slug}'])); + + $match = $m->match('GET', '/posts/2026/hello-world'); + self::assertNotNull($match); + self::assertSame(['year' => '2026', 'slug' => 'hello-world'], $match['params']); + + self::assertNull($m->match('GET', '/posts/twenty/hello-world')); + } + + public function test_a_typed_route_and_an_untyped_route_can_coexist(): void + { + // /users/{id:num} is declared first, so a numeric id takes it and + // anything else falls through to the untyped route. + $m = $this->matcher($this->manifest(['/users/{id:num}', '/users/{name}'])); + + self::assertSame(['id' => '7'], $m->match('GET', '/users/7')['params']); + self::assertSame(['name' => 'ada'], $m->match('GET', '/users/ada')['params']); + } + + // ── RouteParameter ────────────────────────────────────────────────────── + + public function test_parse_reports_names_and_types(): void + { + self::assertSame( + [['name' => 'year', 'type' => 'num'], ['name' => 'slug', 'type' => '']], + RouteParameter::parse('/posts/{year:num}/{slug}'), + ); + } + + public function test_an_unknown_type_is_rejected(): void + { + self::assertFalse(RouteParameter::isValidType('number')); + + $this->expectExceptionMessageMatches('/Unknown route parameter type \[number\]/'); + RouteParameter::pattern('number'); + } +} diff --git a/tests/Unit/Kernel/Ports/LockContractTest.php b/tests/Unit/Kernel/Ports/LockContractTest.php new file mode 100644 index 0000000..b4d1363 --- /dev/null +++ b/tests/Unit/Kernel/Ports/LockContractTest.php @@ -0,0 +1,180 @@ +dir = sys_get_temp_dir() . '/hkm-lock-test-' . bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + if (!is_dir($this->dir)) { + return; + } + foreach (glob($this->dir . '/**/*') ?: [] as $f) { + if (is_file($f)) { + @unlink($f); + } + } + foreach (glob($this->dir . '/*') ?: [] as $f) { + is_dir($f) ? @rmdir($f) : @unlink($f); + } + @rmdir($this->dir); + } + + /** @return array */ + public static function implementations(): array + { + return [ + 'process-local' => [static function (string $dir, int $ttl): Lock { + // One cache instance per factory call would give each lock its own + // table; the caller shares a cache to test contention. + static $caches = []; + $caches[$dir] ??= new InMemoryCache(); + + return $caches[$dir]->lock('job:nightly', $ttl); + }], + 'file' => [static function (string $dir, int $ttl): Lock { + return (new FileCache($dir))->lock('job:nightly', $ttl); + }], + ]; + } + + #[DataProvider('implementations')] + public function test_acquire_succeeds_when_free(callable $make): void + { + self::assertTrue($make($this->dir, 60)->acquire()); + } + + #[DataProvider('implementations')] + public function test_second_acquirer_is_refused_while_held(callable $make): void + { + $first = $make($this->dir, 60); + $second = $make($this->dir, 60); + + self::assertTrue($first->acquire(), 'first caller must win'); + self::assertFalse($second->acquire(), 'lock must be mutually exclusive'); + } + + #[DataProvider('implementations')] + public function test_release_frees_the_lock_for_the_next_caller(callable $make): void + { + $first = $make($this->dir, 60); + self::assertTrue($first->acquire()); + self::assertTrue($first->release()); + + self::assertTrue($make($this->dir, 60)->acquire(), 'lock must be reusable after release'); + } + + #[DataProvider('implementations')] + public function test_a_non_owner_cannot_release(callable $make): void + { + $owner = $make($this->dir, 60); + $intruder = $make($this->dir, 60); + + self::assertTrue($owner->acquire()); + + // The intruder never acquired, so its token differs — release must refuse. + self::assertFalse($intruder->release(), 'release() must be owner-checked'); + self::assertFalse($make($this->dir, 60)->acquire(), 'lock must still be held'); + } + + #[DataProvider('implementations')] + public function test_an_expired_lock_is_reclaimed(callable $make): void + { + // A 1s TTL that has already elapsed stands in for a crashed holder. + $stale = $make($this->dir, 1); + self::assertTrue($stale->acquire()); + + sleep(2); + + self::assertTrue($make($this->dir, 60)->acquire(), 'expired lock must be reclaimable'); + } + + #[DataProvider('implementations')] + public function test_force_release_ignores_ownership(callable $make): void + { + $owner = $make($this->dir, 60); + self::assertTrue($owner->acquire()); + + $make($this->dir, 60)->forceRelease(); + + self::assertTrue($make($this->dir, 60)->acquire(), 'forceRelease must clear regardless of owner'); + } + + #[DataProvider('implementations')] + public function test_block_runs_the_callback_and_always_releases(callable $make): void + { + $result = $make($this->dir, 60)->block(1, static fn(): string => 'done'); + + self::assertSame('done', $result); + self::assertTrue($make($this->dir, 60)->acquire(), 'block() must release after the callback'); + } + + #[DataProvider('implementations')] + public function test_block_releases_even_when_the_callback_throws(callable $make): void + { + try { + $make($this->dir, 60)->block(1, static function (): never { + throw new \RuntimeException('boom'); + }); + self::fail('the callback exception must propagate'); + } catch (\RuntimeException $e) { + self::assertSame('boom', $e->getMessage()); + } + + self::assertTrue( + $make($this->dir, 60)->acquire(), + 'a throwing callback must not strand the lock until its TTL', + ); + } + + #[DataProvider('implementations')] + public function test_block_times_out_when_the_lock_stays_held(callable $make): void + { + self::assertTrue($make($this->dir, 60)->acquire()); + + $this->expectException(LockTimeoutException::class); + $make($this->dir, 60)->block(1); + } + + #[DataProvider('implementations')] + public function test_owner_tokens_are_unique_per_lock(callable $make): void + { + self::assertNotSame( + $make($this->dir, 60)->owner(), + $make($this->dir, 60)->owner(), + 'a shared owner token would let any caller release any lock', + ); + } +} diff --git a/tests/Unit/Kernel/Ports/LoggerPortTest.php b/tests/Unit/Kernel/Ports/LoggerPortTest.php new file mode 100644 index 0000000..118da61 --- /dev/null +++ b/tests/Unit/Kernel/Ports/LoggerPortTest.php @@ -0,0 +1,155 @@ +file = sys_get_temp_dir() . '/hkm-log-' . bin2hex(random_bytes(6)) . '/app.log'; + } + + protected function tearDown(): void + { + if (is_file($this->file)) { + @unlink($this->file); + } + @rmdir(dirname($this->file)); + } + + private function contents(): string + { + return is_file($this->file) ? (string) file_get_contents($this->file) : ''; + } + + // ── LogLevel ──────────────────────────────────────────────────────────── + + public function test_levels_are_ordered_by_rfc5424_severity(): void + { + self::assertTrue(LogLevel::Emergency->passes(LogLevel::Error), 'emergency is more severe than error'); + self::assertFalse(LogLevel::Debug->passes(LogLevel::Error), 'debug is less severe than error'); + self::assertTrue(LogLevel::Error->passes(LogLevel::Error), 'a level always passes its own threshold'); + } + + public function test_an_unknown_level_string_parses_to_debug(): void + { + // Never throw on a bad level — losing one line's severity beats losing + // the operation that was being logged. + self::assertSame(LogLevel::Debug, LogLevel::parse('not-a-level')); + self::assertSame(LogLevel::Warning, LogLevel::parse('WARNING')); + } + + // ── FileLogger ────────────────────────────────────────────────────────── + + public function test_writes_a_line_with_timestamp_level_and_message(): void + { + (new FileLogger($this->file))->warning('disk almost full'); + + $out = $this->contents(); + self::assertStringContainsString('warning: disk almost full', $out); + self::assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2}T/', $out); + } + + public function test_creates_the_log_directory_on_demand(): void + { + self::assertDirectoryDoesNotExist(dirname($this->file)); + + (new FileLogger($this->file))->info('first line'); + + self::assertFileExists($this->file); + } + + public function test_interpolates_placeholders_from_context(): void + { + (new FileLogger($this->file))->error('tenant {tenant} failed over', ['tenant' => 'acme']); + + self::assertStringContainsString('tenant acme failed over', $this->contents()); + } + + public function test_context_is_also_kept_as_structured_json(): void + { + (new FileLogger($this->file))->info('query slow', ['ms' => 1420]); + + // Human-readable message AND machine-parseable context on the same line. + self::assertStringContainsString('{"ms":1420}', $this->contents()); + } + + public function test_a_non_scalar_placeholder_is_left_alone_not_printed_as_Array(): void + { + (new FileLogger($this->file))->info('payload {data}', ['data' => ['a' => 1]]); + + $out = $this->contents(); + self::assertStringContainsString('payload {data}', $out, 'token stays literal'); + self::assertStringNotContainsString('Array', $out); + self::assertStringContainsString('{"data":{"a":1}}', $out, 'value still travels in context'); + } + + public function test_a_throwable_in_context_is_summarised_not_serialised_whole(): void + { + (new FileLogger($this->file))->error('boom', ['exception' => new \RuntimeException('kaboom')]); + + $out = $this->contents(); + self::assertStringContainsString('RuntimeException', $out); + self::assertStringContainsString('kaboom', $out); + } + + public function test_unencodable_context_does_not_throw(): void + { + // Malformed UTF-8 would make json_encode fail. A logger must never take + // down the operation it was only observing. + (new FileLogger($this->file))->info('bad bytes', ['raw' => "\xB1\x31"]); + + self::assertStringContainsString('unencodable', $this->contents()); + } + + public function test_records_below_the_threshold_are_dropped(): void + { + $logger = new FileLogger($this->file, LogLevel::Warning); + + $logger->debug('noisy'); + $logger->info('also noisy'); + $logger->error('this matters'); + + $out = $this->contents(); + self::assertStringNotContainsString('noisy', $out); + self::assertStringContainsString('this matters', $out); + } + + public function test_appends_rather_than_truncating(): void + { + $logger = new FileLogger($this->file); + $logger->info('first'); + $logger->info('second'); + + self::assertSame(2, substr_count($this->contents(), PHP_EOL)); + } + + // ── NullLogger ────────────────────────────────────────────────────────── + + public function test_null_logger_satisfies_the_port_and_discards(): void + { + $logger = new NullLogger(); + + self::assertInstanceOf(LoggerPort::class, $logger); + + // Must accept every level without throwing and record nothing. + $logger->emergency('ignored'); + $logger->log('error', 'ignored', ['k' => 'v']); + + self::assertSame('', $this->contents()); + } +} diff --git a/tests/Unit/Kernel/Ports/QueuePortTest.php b/tests/Unit/Kernel/Ports/QueuePortTest.php new file mode 100644 index 0000000..c0248af --- /dev/null +++ b/tests/Unit/Kernel/Ports/QueuePortTest.php @@ -0,0 +1,155 @@ +dir = sys_get_temp_dir() . '/hkm-queue-' . bin2hex(random_bytes(6)); + $this->queue = new FileQueue($this->dir); + } + + protected function tearDown(): void + { + foreach (glob($this->dir . '/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->dir); + } + + public function test_an_empty_queue_pops_null(): void + { + self::assertNull($this->queue->pop()); + } + + public function test_a_pushed_job_pops_back_as_a_typed_payload(): void + { + $id = $this->queue->push('App\\Jobs\\SendMail', ['to' => 'a@b.test']); + + $payload = $this->queue->pop(); + + self::assertInstanceOf(JobPayload::class, $payload); + self::assertSame($id, $payload->jobId()); + self::assertSame('App\\Jobs\\SendMail', $payload->jobClass()); + self::assertSame(['to' => 'a@b.test'], $payload->data()); + self::assertSame(0, $payload->attempts()); + } + + public function test_popping_reserves_the_job_so_a_second_worker_cannot_take_it(): void + { + $this->queue->push('App\\Jobs\\X', []); + + self::assertNotNull($this->queue->pop()); + self::assertNull($this->queue->pop(), 'a reserved job must not be handed out twice'); + } + + public function test_jobs_pop_in_fifo_order(): void + { + $this->queue->push('App\\Jobs\\First', []); + $this->queue->push('App\\Jobs\\Second', []); + + self::assertSame('App\\Jobs\\First', $this->queue->pop()?->jobClass()); + self::assertSame('App\\Jobs\\Second', $this->queue->pop()?->jobClass()); + } + + public function test_a_delayed_job_is_not_yet_due(): void + { + $this->queue->later(3600, 'App\\Jobs\\Later', []); + + self::assertNull($this->queue->pop(), 'not available until its delay elapses'); + self::assertSame(1, $this->queue->size(), 'but it is still queued'); + } + + public function test_ack_leaves_the_queue_empty(): void + { + $this->queue->push('App\\Jobs\\X', []); + $payload = $this->queue->pop(); + + $this->queue->ack($payload); + + self::assertSame(0, $this->queue->size()); + self::assertNull($this->queue->pop()); + } + + public function test_release_requeues_with_an_incremented_attempt_count(): void + { + $this->queue->push('App\\Jobs\\Flaky', ['n' => 1]); + + $first = $this->queue->pop(); + self::assertSame(0, $first->attempts()); + + $this->queue->release($first); + + $second = $this->queue->pop(); + self::assertNotNull($second, 'a released job comes back'); + self::assertSame(1, $second->attempts(), 'attempt count must advance or retries loop forever'); + self::assertSame(['n' => 1], $second->data(), 'payload survives the round trip'); + } + + public function test_release_with_a_delay_holds_the_job_back(): void + { + $this->queue->push('App\\Jobs\\Flaky', []); + $payload = $this->queue->pop(); + + $this->queue->release($payload, 3600); + + self::assertNull($this->queue->pop(), 'backoff must actually delay the retry'); + self::assertSame(1, $this->queue->size()); + } + + public function test_fail_removes_the_job_from_the_queue(): void + { + $this->queue->push('App\\Jobs\\Broken', []); + $payload = $this->queue->pop(); + + $this->queue->fail($payload, new \RuntimeException('nope')); + + self::assertSame(0, $this->queue->size(), 'a dead-lettered job must not come back'); + self::assertNull($this->queue->pop()); + } + + public function test_fail_records_the_job_rather_than_dropping_it(): void + { + $this->queue->push('App\\Jobs\\Broken', ['k' => 'v']); + $payload = $this->queue->pop(); + + $this->queue->fail($payload, new \RuntimeException('kaboom')); + + // A permanently-failing job that simply vanishes is the hardest kind of + // bug to notice — it must be inspectable somewhere. + self::assertSame(1, $this->queue->size('default.failed')); + + $dead = $this->queue->pop('default.failed'); + self::assertSame('App\\Jobs\\Broken', $dead?->jobClass()); + self::assertSame(['k' => 'v'], $dead?->data()); + } + + public function test_queues_are_isolated_from_each_other(): void + { + $this->queue->push('App\\Jobs\\Mail', [], 'emails'); + + self::assertNull($this->queue->pop('default')); + self::assertSame('App\\Jobs\\Mail', $this->queue->pop('emails')?->jobClass()); + } +} diff --git a/tests/Unit/Kernel/Routing/UrlGeneratorTest.php b/tests/Unit/Kernel/Routing/UrlGeneratorTest.php new file mode 100644 index 0000000..dc1692f --- /dev/null +++ b/tests/Unit/Kernel/Routing/UrlGeneratorTest.php @@ -0,0 +1,179 @@ + ['name' => 'home', 'handler' => 'C@m'], + 'GET /users/{id:num}' => ['name' => 'user.show', 'handler' => 'C@m'], + 'GET /posts/{year:num}/{slug}' => ['name' => 'post.show', 'handler' => 'C@m'], + 'GET /search' => ['name' => 'search', 'handler' => 'C@m'], + 'GET /files/{path:any}' => ['name' => 'file.download', 'handler' => 'C@m'], + 'POST /users' => ['name' => 'user.store', 'handler' => 'C@m'], + 'GET /unnamed' => ['handler' => 'C@m'], + ], + base: 'https://app.example.test', + secret: $secret, + ); + } + + // ── Named routes ──────────────────────────────────────────────────────── + + public function test_generates_a_static_route(): void + { + self::assertSame('/', $this->generator()->route('home')); + } + + public function test_substitutes_parameters(): void + { + self::assertSame('/users/7', $this->generator()->route('user.show', ['id' => 7])); + self::assertSame( + '/posts/2026/hello-world', + $this->generator()->route('post.show', ['year' => 2026, 'slug' => 'hello-world']), + ); + } + + public function test_extra_parameters_become_a_query_string(): void + { + self::assertSame('/search?q=router', $this->generator()->route('search', ['q' => 'router'])); + } + + public function test_can_generate_an_absolute_url(): void + { + self::assertSame( + 'https://app.example.test/users/7', + $this->generator()->route('user.show', ['id' => 7], absolute: true), + ); + } + + public function test_reports_the_method_for_a_named_route(): void + { + self::assertSame('POST', $this->generator()->methodFor('user.store')); + self::assertNull($this->generator()->methodFor('nope')); + } + + public function test_an_unnamed_route_is_not_addressable(): void + { + self::assertFalse($this->generator()->has('unnamed')); + } + + // ── Failure modes that would otherwise be silent 404s ─────────────────── + + public function test_an_unknown_name_throws(): void + { + $this->expectExceptionMessageMatches('/Unknown route name \[nope\]/'); + $this->generator()->route('nope'); + } + + public function test_a_missing_parameter_throws(): void + { + $this->expectExceptionMessageMatches('/needs a value for \{id\}/'); + $this->generator()->route('user.show'); + } + + public function test_a_value_violating_its_type_throws(): void + { + // Generating a URL the matcher provably cannot match is always a bug — + // fail at the call site instead of producing a mystery 404. + $this->expectExceptionMessageMatches('/does not satisfy type \[num\]/'); + $this->generator()->route('user.show', ['id' => 'abc']); + } + + public function test_a_value_may_not_smuggle_in_a_path_separator(): void + { + // '/' in an untyped segment would change the route the URL resolves to. + $this->expectExceptionMessageMatches('/does not satisfy type/'); + $this->generator()->route('post.show', ['year' => 2026, 'slug' => 'a/b']); + } + + public function test_an_any_parameter_may_contain_slashes(): void + { + // rawurlencode escapes them; the catch-all still matches on the way back in. + $url = $this->generator()->route('file.download', ['path' => 'a/b.txt']); + + self::assertSame('/files/a%2Fb.txt', $url); + } + + // ── Signed URLs ───────────────────────────────────────────────────────── + + public function test_a_signed_url_validates(): void + { + $url = $this->generator()->signedRoute('user.show', ['id' => 7]); + + self::assertStringContainsString('signature=', $url); + self::assertTrue($this->generator()->hasValidSignature($url)); + } + + public function test_tampering_with_a_parameter_invalidates_the_signature(): void + { + $url = $this->generator()->signedRoute('user.show', ['id' => 7]); + + self::assertFalse($this->generator()->hasValidSignature(str_replace('/7', '/8', $url))); + } + + public function test_an_unsigned_url_is_not_valid(): void + { + self::assertFalse($this->generator()->hasValidSignature('/users/7')); + } + + public function test_an_expired_signed_url_is_rejected(): void + { + $url = $this->generator()->signedRoute('user.show', ['id' => 7], expiresIn: -60); + + // Signature is intact; the deadline has passed. + self::assertFalse($this->generator()->hasValidSignature($url)); + } + + public function test_a_future_expiry_is_accepted(): void + { + $url = $this->generator()->signedRoute('user.show', ['id' => 7], expiresIn: 3600); + + self::assertTrue($this->generator()->hasValidSignature($url)); + } + + public function test_the_expiry_itself_cannot_be_extended(): void + { + $url = $this->generator()->signedRoute('user.show', ['id' => 7], expiresIn: -60); + $past = (string) (time() - 60); + + // Pushing 'expires' forward breaks the signature, because it is signed. + self::assertFalse( + $this->generator()->hasValidSignature(str_replace($past, (string) (time() + 3600), $url)), + ); + } + + public function test_signing_fails_closed_without_a_secret(): void + { + // A URL signed with an empty key is forgeable by anyone and would look + // identical to a real one — refuse to produce it at all. + $this->expectExceptionMessageMatches('/no signing secret configured/'); + $this->generator(secret: '')->signedRoute('user.show', ['id' => 7]); + } + + public function test_verification_fails_closed_without_a_secret(): void + { + $signed = $this->generator()->signedRoute('user.show', ['id' => 7]); + + self::assertFalse($this->generator(secret: '')->hasValidSignature($signed)); + } + + public function test_a_signature_from_a_different_key_is_rejected(): void + { + $signed = $this->generator()->signedRoute('user.show', ['id' => 7]); + + self::assertFalse($this->generator(secret: 'a-completely-different-key')->hasValidSignature($signed)); + } +} diff --git a/tests/Unit/Kernel/Security/CsrfTokenExpiryTest.php b/tests/Unit/Kernel/Security/CsrfTokenExpiryTest.php new file mode 100644 index 0000000..ce929b7 --- /dev/null +++ b/tests/Unit/Kernel/Security/CsrfTokenExpiryTest.php @@ -0,0 +1,117 @@ +setTimestamp($this->at); + } + + public function timestamp(): int + { + return $this->at; + } + }; + } + + public function test_a_freshly_minted_token_is_valid(): void + { + $now = 1_800_000_000; + $clock = $this->clockAt($now); + + $token = CsrfTokenLayer::make(self::SECRET, '', self::LIFETIME, '', $clock); + + self::assertTrue(CsrfTokenLayer::valid(self::SECRET, $token, '', self::LIFETIME, $clock)); + } + + public function test_a_token_is_still_valid_in_the_next_half_life_window(): void + { + $now = 1_800_000_000; + $token = CsrfTokenLayer::make(self::SECRET, '', self::LIFETIME, '', $this->clockAt($now)); + + // 7 hours later — past one half-life (6h), inside the grace window. + $later = $this->clockAt($now + 7 * 3600); + + self::assertTrue(CsrfTokenLayer::valid(self::SECRET, $token, '', self::LIFETIME, $later)); + } + + public function test_an_expired_token_is_rejected(): void + { + $now = 1_800_000_000; + $token = CsrfTokenLayer::make(self::SECRET, '', self::LIFETIME, '', $this->clockAt($now)); + + // A full day later — well past the lifetime plus its grace window. + $later = $this->clockAt($now + 24 * 3600); + + self::assertFalse(CsrfTokenLayer::valid(self::SECRET, $token, '', self::LIFETIME, $later)); + } + + public function test_a_token_from_the_future_is_rejected(): void + { + $now = 1_800_000_000; + $token = CsrfTokenLayer::make(self::SECRET, '', self::LIFETIME, '', $this->clockAt($now + 24 * 3600)); + + self::assertFalse(CsrfTokenLayer::valid(self::SECRET, $token, '', self::LIFETIME, $this->clockAt($now))); + } + + public function test_a_token_bound_to_one_client_does_not_validate_for_another(): void + { + $clock = $this->clockAt(1_800_000_000); + $token = CsrfTokenLayer::make(self::SECRET, 'session-aaa', self::LIFETIME, '', $clock); + + self::assertTrue(CsrfTokenLayer::valid(self::SECRET, $token, 'session-aaa', self::LIFETIME, $clock)); + self::assertFalse(CsrfTokenLayer::valid(self::SECRET, $token, 'session-bbb', self::LIFETIME, $clock)); + } + + public function test_a_token_signed_with_a_different_secret_is_rejected(): void + { + $clock = $this->clockAt(1_800_000_000); + $token = CsrfTokenLayer::make(self::SECRET, '', self::LIFETIME, '', $clock); + + self::assertFalse(CsrfTokenLayer::valid('a-different-secret', $token, '', self::LIFETIME, $clock)); + } + + public function test_the_default_clock_is_the_real_one(): void + { + // Omitting the clock must behave exactly as before it was injectable. + $token = CsrfTokenLayer::make(self::SECRET); + + self::assertTrue(CsrfTokenLayer::valid(self::SECRET, $token)); + } + + public function test_system_clock_reports_the_current_time(): void + { + $clock = new SystemClock(); + + self::assertEqualsWithDelta(time(), $clock->timestamp(), 2.0); + self::assertEqualsWithDelta(time(), $clock->now()->getTimestamp(), 2.0); + } +} From 2c490ccbf452664d65f329aee47bfe1887a53af6 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 6 Aug 2026 04:03:59 +0300 Subject: [PATCH 097/140] feat(plugins): adopt the new kernel ports; add Logger plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logger (NEW, solves logging.application): FileLogger, StreamLogger, NullLogger, PsrLoggerBridge behind Kernel\Ports\LoggerPort. - Commands: stop binding Psr\Log\LoggerInterface to a NullLogger — that single binding was discarding every log line in the application. Resolve LoggerPort. - Database, Tenancy: migrate off Psr\Log onto LoggerPort. - RedisCache: RedisLock (SET NX PX + Lua compare-and-delete) and the QueuePort read side (pop/ack/release/fail) with a dead-letter list. - Mail, Validation, Storage, Edge: read config through the compiled manifest instead of four copies of a project-file-REPLACES-plugin-file lookup, so a project now overrides only the keys it names. - DevTools: config:show / config:clear. - Auth: drop docblock references to FirewallLayer/RateLimiterLayer, which the kernel does not ship. --- plugins/Auth/README.md | 2 +- plugins/Auth/Security/JwtAuthLayer.php | 2 - .../Logging/CommandExecutionLogger.php | 4 +- plugins/Commands/Provider.php | 11 +- plugins/Commands/module.json | 79 +++++----- .../Persistence/ConnectionManager.php | 4 +- .../MultiDriverDatabaseAdapter.php | 4 +- plugins/Database/Provider.php | 11 +- .../DevTools/Commands/ConfigClearCommand.php | 53 +++++++ .../DevTools/Commands/ConfigShowCommand.php | 136 ++++++++++++++++++ plugins/DevTools/Provider.php | 4 + plugins/Edge/Support/helpers.php | 58 +++++--- .../Logger/Infrastructure/AbstractLogger.php | 102 +++++++++++++ plugins/Logger/Infrastructure/FileLogger.php | 61 ++++++++ plugins/Logger/Infrastructure/NullLogger.php | 33 +++++ .../Logger/Infrastructure/PsrLoggerBridge.php | 38 +++++ .../Logger/Infrastructure/StreamLogger.php | 64 +++++++++ plugins/Logger/Provider.php | 95 ++++++++++++ plugins/Logger/config/logger.php | 38 +++++ plugins/Logger/module.json | 15 ++ plugins/Mail/Provider.php | 29 ++-- .../Infrastructure/RedisCacheAdapter.php | 11 ++ .../RedisCache/Infrastructure/RedisLock.php | 84 +++++++++++ .../Infrastructure/RedisQueueAdapter.php | 100 ++++++++++++- plugins/Storage/Support/helpers.php | 59 +++++--- .../Application/Services/AuditService.php | 6 +- .../TenantConnectionResolver.php | 6 +- plugins/Tenancy/Provider.php | 10 +- plugins/Validation/Provider.php | 21 +-- 29 files changed, 1010 insertions(+), 130 deletions(-) create mode 100644 plugins/DevTools/Commands/ConfigClearCommand.php create mode 100644 plugins/DevTools/Commands/ConfigShowCommand.php create mode 100644 plugins/Logger/Infrastructure/AbstractLogger.php create mode 100644 plugins/Logger/Infrastructure/FileLogger.php create mode 100644 plugins/Logger/Infrastructure/NullLogger.php create mode 100644 plugins/Logger/Infrastructure/PsrLoggerBridge.php create mode 100644 plugins/Logger/Infrastructure/StreamLogger.php create mode 100644 plugins/Logger/Provider.php create mode 100644 plugins/Logger/config/logger.php create mode 100644 plugins/Logger/module.json create mode 100644 plugins/RedisCache/Infrastructure/RedisLock.php diff --git a/plugins/Auth/README.md b/plugins/Auth/README.md index 0583d7d..b28dfed 100644 --- a/plugins/Auth/README.md +++ b/plugins/Auth/README.md @@ -185,7 +185,7 @@ Wire the layers in the kernel builder; they run before any module loads and ```php ->withSecurity([ - new FirewallLayer(...), new RateLimiterLayer(...), new CsrfTokenLayer(...), + new CsrfTokenLayer(...), // the only layer the kernel ships new JwtAuthLayer( secret: env('JWT_SECRET'), algo: env('JWT_ALGO', 'HS256'), diff --git a/plugins/Auth/Security/JwtAuthLayer.php b/plugins/Auth/Security/JwtAuthLayer.php index fa36e95..015e6e8 100644 --- a/plugins/Auth/Security/JwtAuthLayer.php +++ b/plugins/Auth/Security/JwtAuthLayer.php @@ -19,8 +19,6 @@ * token validator, so this plugin provides one. Wire it in a project bootstrap: * * ->withSecurity([ - * new FirewallLayer(...), - * new RateLimiterLayer(...), * new JwtAuthLayer(secret: env('JWT_SECRET'), algo: 'HS256'), * ]) * diff --git a/plugins/Commands/Logging/CommandExecutionLogger.php b/plugins/Commands/Logging/CommandExecutionLogger.php index 88ff6cc..6d1d518 100644 --- a/plugins/Commands/Logging/CommandExecutionLogger.php +++ b/plugins/Commands/Logging/CommandExecutionLogger.php @@ -4,7 +4,7 @@ namespace Plugins\Commands\Logging; -use Psr\Log\LoggerInterface; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; use Psr\Log\LogLevel; final class CommandExecutionLogger @@ -13,7 +13,7 @@ final class CommandExecutionLogger private string $commandName = ''; public function __construct( - private readonly LoggerInterface $logger, + private readonly LoggerPort $logger, ) { $this->startTime = microtime(true); } diff --git a/plugins/Commands/Provider.php b/plugins/Commands/Provider.php index 0d9947c..9f217ed 100644 --- a/plugins/Commands/Provider.php +++ b/plugins/Commands/Provider.php @@ -124,11 +124,14 @@ public function register(ModuleContainer $container): void ); // Register enterprise feature classes (all use the single infrastructure service!) - $container->singleton(\Psr\Log\LoggerInterface::class, fn($c) => - new \Psr\Log\NullLogger() - ); + // + // NOTE: this used to bind Psr\Log\LoggerInterface to a NullLogger — the + // ONLY logger binding in the codebase — so every command-audit line, and + // every line the Database/Tenancy/EventBus components wrote, was silently + // discarded. Resolve the real LoggerPort instead; the Logger plugin + // supplies a file-backed default when the project has not wired one. $container->singleton(CommandExecutionLogger::class, fn($c) => - new CommandExecutionLogger($c->make(\Psr\Log\LoggerInterface::class)) + new CommandExecutionLogger($c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort::class)) ); $container->singleton(DeploymentLockManager::class, fn($c) => new DeploymentLockManager($c->make(CommandsInfrastructureService::class)) diff --git a/plugins/Commands/module.json b/plugins/Commands/module.json index 5f0d5d9..78d7d27 100644 --- a/plugins/Commands/module.json +++ b/plugins/Commands/module.json @@ -1,42 +1,41 @@ { - "name": "commands", - "version": "1.0.0", - "solves": "system.commands", - "type": "module", - - "requires": [], - "exposes": [], - - "commands": [ - "module:add", - "module:remove", - "migrate:run", - "migrate:rollback", - "migrate:reset", - "migrate:refresh", - "migrate:fresh", - "migrate:status", - "migrate:pending", - "migrate:install", - "migrate:to", - "migrate:redo", - "migrate:generate", - "migrate:diff", - "migrate:check", - "migrate:lint", - "migrate:squash", - "migrate:breakpoint", - "make:migration", - "make:seeder", - "make:factory", - "seed:run", - "db:seed", - "tenant:migrate", - "tenant:refresh", - "tenant:reset", - "tenant:rollback", - "tenant:status" - ], - - "config": [] + "name": "commands", + "version": "1.0.0", + "solves": "system.commands", + "type": "module", + "requires": [ + "logging.application" + ], + "exposes": [], + "commands": [ + "module:add", + "module:remove", + "migrate:run", + "migrate:rollback", + "migrate:reset", + "migrate:refresh", + "migrate:fresh", + "migrate:status", + "migrate:pending", + "migrate:install", + "migrate:to", + "migrate:redo", + "migrate:generate", + "migrate:diff", + "migrate:check", + "migrate:lint", + "migrate:squash", + "migrate:breakpoint", + "make:migration", + "make:seeder", + "make:factory", + "seed:run", + "db:seed", + "tenant:migrate", + "tenant:refresh", + "tenant:reset", + "tenant:rollback", + "tenant:status" + ], + "config": [] } diff --git a/plugins/Database/Infrastructure/Persistence/ConnectionManager.php b/plugins/Database/Infrastructure/Persistence/ConnectionManager.php index a2c28d2..84a948f 100644 --- a/plugins/Database/Infrastructure/Persistence/ConnectionManager.php +++ b/plugins/Database/Infrastructure/Persistence/ConnectionManager.php @@ -5,7 +5,7 @@ namespace Plugins\Database\Infrastructure\Persistence; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Psr\Log\LoggerInterface; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; use Plugins\Database\API\Contracts\DatabaseConfigurationContract; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\Database\Exceptions\ConnectionException; @@ -30,7 +30,7 @@ final class ConnectionManager implements DatabaseConnectionManagerContract public function __construct( private readonly string $defaultName = 'default', - private readonly ?LoggerInterface $logger = null, + private readonly ?LoggerPort $logger = null, private readonly bool $logQueries = false, ) {} diff --git a/plugins/Database/Infrastructure/Persistence/MultiDriverDatabaseAdapter.php b/plugins/Database/Infrastructure/Persistence/MultiDriverDatabaseAdapter.php index 052f28b..f865b51 100644 --- a/plugins/Database/Infrastructure/Persistence/MultiDriverDatabaseAdapter.php +++ b/plugins/Database/Infrastructure/Persistence/MultiDriverDatabaseAdapter.php @@ -8,7 +8,7 @@ use PDO; use PDOException; use PDOStatement; -use Psr\Log\LoggerInterface; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; use Plugins\Database\API\Contracts\DatabaseConfigurationContract; use Plugins\Database\Exceptions\ConnectionException; @@ -40,7 +40,7 @@ final class MultiDriverDatabaseAdapter implements DatabasePort public function __construct( private readonly DatabaseConfigurationContract $config, - private readonly ?LoggerInterface $logger = null, + private readonly ?LoggerPort $logger = null, private readonly bool $logQueries = false, private readonly float $slowQueryThresholdMs = 200.0, ) { diff --git a/plugins/Database/Provider.php b/plugins/Database/Provider.php index 0a7fc15..ebb50c5 100644 --- a/plugins/Database/Provider.php +++ b/plugins/Database/Provider.php @@ -11,7 +11,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Events\EventBus; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Psr\Log\LoggerInterface; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; use Plugins\Database\API\Contracts\DatabaseConfigurationContract; use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; use Plugins\Database\Infrastructure\Drivers\DatabaseConfigurationFactory; @@ -132,14 +132,15 @@ private function registerPooledPort(ModuleContainer $container, bool $logQueries } /** - * Resolve a PSR-3 logger if one is bound; observability is optional. + * Resolve the app logger if one is bound; observability is optional here — + * the database must work in a bootstrap that has not wired a LoggerPort. */ - private static function optionalLogger(mixed $container): ?LoggerInterface + private static function optionalLogger(mixed $container): ?LoggerPort { try { - $logger = $container->make(LoggerInterface::class); + $logger = $container->make(LoggerPort::class); - return $logger instanceof LoggerInterface ? $logger : null; + return $logger instanceof LoggerPort ? $logger : null; } catch (\Throwable) { return null; } diff --git a/plugins/DevTools/Commands/ConfigClearCommand.php b/plugins/DevTools/Commands/ConfigClearCommand.php new file mode 100644 index 0000000..06fa0ca --- /dev/null +++ b/plugins/DevTools/Commands/ConfigClearCommand.php @@ -0,0 +1,53 @@ +name = 'config:clear'; + $this->description = 'Delete the compiled config manifest'; + } + + protected function handle(): int + { + $path = Paths::cache('manifests/config-manifest.php'); + + if (!is_file($path)) { + $this->info('No compiled config manifest — nothing to clear.'); + + return self::SUCCESS; + } + + if (!@unlink($path)) { + $this->error("Could not delete {$path} — check file ownership."); + + return self::FAILURE; + } + + if (function_exists('opcache_invalidate')) { + @opcache_invalidate($path, true); + } + + $this->success('Config manifest cleared. It recompiles on the next boot.'); + + return self::SUCCESS; + } +} diff --git a/plugins/DevTools/Commands/ConfigShowCommand.php b/plugins/DevTools/Commands/ConfigShowCommand.php new file mode 100644 index 0000000..b8d0798 --- /dev/null +++ b/plugins/DevTools/Commands/ConfigShowCommand.php @@ -0,0 +1,136 @@ +name = 'config:show'; + $this->description = 'Show resolved configuration (group, dotted key, or everything)'; + + $this->addArgument('key', 'Config group or dotted key', required: false); + $this->addOption('json', 'j', 'Output raw JSON'); + } + + protected function handle(): int + { + $items = ManifestReader::readCompiled('config-manifest.php'); + + if ($items === []) { + $this->warning('No config manifest found. Run the app once (or config:cache) to compile it.'); + + return self::SUCCESS; + } + + $config = new Repository($items); + $key = (string) ($this->argument('key') ?? ''); + + if ($key === '') { + return $this->showGroups($items); + } + + if (!$config->has($key)) { + $this->error("Config key [{$key}] is not set."); + + return self::FAILURE; + } + + $value = $config->get($key); + + if ($this->hasOption('json')) { + $this->info((string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + if (!is_array($value)) { + $this->info($key . ' = ' . $this->render($value)); + + return self::SUCCESS; + } + + $rows = []; + foreach ($this->flatten($value, $key) as $dotted => $leaf) { + $rows[] = [$dotted, $this->render($leaf)]; + } + + $this->table()->headers(['Key', 'Value'])->rows($rows)->render(); + + return self::SUCCESS; + } + + /** @param array $items */ + private function showGroups(array $items): int + { + if ($this->hasOption('json')) { + $this->info((string) json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + $rows = []; + foreach ($items as $group => $values) { + $rows[] = [ + (string) $group, + is_array($values) ? (string) count($this->flatten($values, (string) $group)) : '1', + ]; + } + + $this->table()->headers(['Group', 'Keys'])->rows($rows)->render(); + $this->newLine(); + $this->info(count($items) . ' config group(s). Pass a group or dotted key to drill in.'); + + return self::SUCCESS; + } + + /** + * Flatten nested config to dotted leaves so the table stays readable. + * + * @param array $values + * @return array + */ + private function flatten(array $values, string $prefix): array + { + $flat = []; + foreach ($values as $key => $value) { + $dotted = $prefix . '.' . $key; + if (is_array($value) && $value !== [] && !array_is_list($value)) { + $flat += $this->flatten($value, $dotted); + continue; + } + $flat[$dotted] = $value; + } + + return $flat; + } + + private function render(mixed $value): string + { + return match (true) { + $value === null => 'null', + is_bool($value) => $value ? 'true' : 'false', + is_array($value) => (string) json_encode($value, JSON_UNESCAPED_SLASHES), + default => (string) $value, + }; + } +} diff --git a/plugins/DevTools/Provider.php b/plugins/DevTools/Provider.php index 2d1ec23..ea516fa 100644 --- a/plugins/DevTools/Provider.php +++ b/plugins/DevTools/Provider.php @@ -10,6 +10,8 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Cli\CliPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; +use Plugins\DevTools\Commands\ConfigClearCommand; +use Plugins\DevTools\Commands\ConfigShowCommand; use Plugins\DevTools\Commands\MakePluginCommand; use Plugins\DevTools\Commands\MakeServiceCommand; use Plugins\DevTools\Commands\ModuleListCommand; @@ -55,5 +57,7 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke $cli->command(ModuleInfoCommand::class); $cli->command(RoutesListCommand::class); $cli->command(ProjectListCommand::class); + $cli->command(ConfigShowCommand::class); + $cli->command(ConfigClearCommand::class); } } diff --git a/plugins/Edge/Support/helpers.php b/plugins/Edge/Support/helpers.php index 98c96d7..3510c41 100644 --- a/plugins/Edge/Support/helpers.php +++ b/plugins/Edge/Support/helpers.php @@ -2,47 +2,65 @@ declare(strict_types=1); -use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; - if (!function_exists('edge_config')) { /** - * Read the edge configuration (config/edge.php), cached per process. A - * project copy at projects//config/edge.php wins over the plugin - * default. + * Read the edge configuration (config/edge.php). * * edge_config(); // full array * edge_config('listen'); // 443 * edge_config('upstreams.nginx'); // dotted access * edge_config('paths.stream', '…'); // value, or fallback if absent * + * Backed by the compiled config manifest, so a project's + * projects//config/edge.php is DEEP-MERGED over the plugin default — + * overriding one upstream keeps the rest of the shipped config, which the + * previous project-file-replaces-plugin-file lookup silently discarded. + * * @return mixed the whole config array, or a single (dotted) key's value */ function edge_config(?string $key = null, mixed $default = null): mixed { - /** @var array|null $config */ - static $config = null; - - if ($config === null) { - $projectFile = Paths::config('edge.php'); - $pluginFile = __DIR__ . '/../config/edge.php'; + if ($key === null) { + $all = function_exists('config') ? config('edge') : null; - $file = is_file($projectFile) ? $projectFile : $pluginFile; - $loaded = require $file; - $config = is_array($loaded) ? $loaded : []; + return is_array($all) && $all !== [] ? $all : edge_config_fallback(); } - if ($key === null) { - return $config; + if (function_exists('config')) { + $value = config('edge.' . $key, $sentinel = new stdClass()); + if ($value !== $sentinel) { + return $value; + } } - $value = $config; + // No manifest compiled (e.g. a unit test that never ran the BootPipeline). + $config = edge_config_fallback(); foreach (explode('.', $key) as $segment) { - if (!is_array($value) || !array_key_exists($segment, $value)) { + if (!is_array($config) || !array_key_exists($segment, $config)) { return $default; } - $value = $value[$segment]; + $config = $config[$segment]; + } + + return $config; + } +} + +if (!function_exists('edge_config_fallback')) { + /** + * The plugin's shipped defaults, used only when no config manifest exists. + * + * @return array + */ + function edge_config_fallback(): array + { + static $config = null; + + if ($config === null) { + $loaded = require __DIR__ . '/../config/edge.php'; + $config = is_array($loaded) ? $loaded : []; } - return $value; + return $config; } } diff --git a/plugins/Logger/Infrastructure/AbstractLogger.php b/plugins/Logger/Infrastructure/AbstractLogger.php new file mode 100644 index 0000000..3f4e9eb --- /dev/null +++ b/plugins/Logger/Infrastructure/AbstractLogger.php @@ -0,0 +1,102 @@ +log('emergency', $message, $context); } + public function alert(string|\Stringable $message, array $context = []): void { $this->log('alert', $message, $context); } + public function critical(string|\Stringable $message, array $context = []): void { $this->log('critical', $message, $context); } + public function error(string|\Stringable $message, array $context = []): void { $this->log('error', $message, $context); } + public function warning(string|\Stringable $message, array $context = []): void { $this->log('warning', $message, $context); } + public function notice(string|\Stringable $message, array $context = []): void { $this->log('notice', $message, $context); } + public function info(string|\Stringable $message, array $context = []): void { $this->log('info', $message, $context); } + public function debug(string|\Stringable $message, array $context = []): void { $this->log('debug', $message, $context); } + + /** + * Replace {placeholder} tokens with matching context values (PSR-3 §1.2). + * + * Only scalars and Stringables are substituted; an array or object context + * value stays as the literal token rather than printing "Array". The + * unsubstituted keys still travel in the structured context, so nothing is + * lost — it just does not get flattened into the human-readable message. + * + * @param array $context + */ + protected function interpolate(string $message, array $context): string + { + if (!str_contains($message, '{')) { + return $message; + } + + $replacements = []; + foreach ($context as $key => $value) { + if ($value === null || is_scalar($value) || $value instanceof \Stringable) { + $replacements['{' . $key . '}'] = (string) $value; + } + } + + return $replacements === [] ? $message : strtr($message, $replacements); + } + + /** + * Encode context for output. Never throws — a logger that fails takes down + * the operation it was only supposed to observe. + * + * @param array $context + */ + protected function encodeContext(array $context): string + { + if ($context === []) { + return ''; + } + + try { + return (string) json_encode( + $this->normalise($context), + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + ); + } catch (\Throwable) { + return '{"_context":"unencodable"}'; + } + } + + /** + * Make a context array safe to encode: exceptions become readable records, + * other objects collapse to their class name. Prevents both a JSON failure + * and accidentally serialising an entire object graph into a log line. + * + * @param array $context + * @return array + */ + private function normalise(array $context): array + { + foreach ($context as $key => $value) { + if ($value instanceof \Throwable) { + $context[$key] = [ + 'class' => $value::class, + 'message' => $value->getMessage(), + 'file' => $value->getFile() . ':' . $value->getLine(), + ]; + continue; + } + if (is_array($value)) { + $context[$key] = $this->normalise($value); + continue; + } + if (is_object($value) && !$value instanceof \Stringable && !$value instanceof \JsonSerializable) { + $context[$key] = $value::class; + } + } + + return $context; + } +} diff --git a/plugins/Logger/Infrastructure/FileLogger.php b/plugins/Logger/Infrastructure/FileLogger.php new file mode 100644 index 0000000..cf1ac83 --- /dev/null +++ b/plugins/Logger/Infrastructure/FileLogger.php @@ -0,0 +1,61 @@ +passes($this->minimum)) { + return; + } + + $line = sprintf( + '[%s] %s: %s %s', + date(DATE_ATOM), + $parsed->value, + $this->interpolate((string) $message, $context), + $this->encodeContext($context), + ); + + $this->append(rtrim($line) . PHP_EOL); + } + + private function append(string $line): void + { + try { + $dir = dirname($this->file); + if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { + return; + } + + @file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX); + } catch (\Throwable) { + // Swallow — see the class docblock. There is nowhere left to report to. + } + } +} diff --git a/plugins/Logger/Infrastructure/NullLogger.php b/plugins/Logger/Infrastructure/NullLogger.php new file mode 100644 index 0000000..df65427 --- /dev/null +++ b/plugins/Logger/Infrastructure/NullLogger.php @@ -0,0 +1,33 @@ + new PsrLoggerBridge($monolog) + * + * and every kernel and plugin component logs through it unchanged. + * + * The reverse direction is deliberately NOT provided. Exposing a LoggerPort as + * a PSR-3 logger would tempt code back into type-hinting Psr\Log\LoggerInterface, + * which is the coupling this port exists to remove. + */ +final class PsrLoggerBridge extends AbstractLogger +{ + public function __construct(private readonly LoggerInterface $psr) {} + + public function log(string $level, string|\Stringable $message, array $context = []): void + { + // PSR-3 loggers do their own interpolation and context handling, so pass + // both through untouched rather than pre-rendering. + try { + $this->psr->log($level, $message, $context); + } catch (\Throwable) { + // A logger must never break its caller. + } + } +} diff --git a/plugins/Logger/Infrastructure/StreamLogger.php b/plugins/Logger/Infrastructure/StreamLogger.php new file mode 100644 index 0000000..018446c --- /dev/null +++ b/plugins/Logger/Infrastructure/StreamLogger.php @@ -0,0 +1,64 @@ +passes($this->minimum)) { + return; + } + + $line = sprintf( + '[%s] %s: %s %s', + date(DATE_ATOM), + $parsed->value, + $this->interpolate((string) $message, $context), + $this->encodeContext($context), + ); + + // Warning and worse to stderr, so `docker logs` error streams are useful. + $stream = $parsed->passes(LogLevel::Warning) ? $this->errStream() : $this->outStream(); + + if (is_resource($stream)) { + @fwrite($stream, rtrim($line) . PHP_EOL); + } + } + + /** @return resource|null */ + private function outStream() + { + return $this->out ??= (defined('STDOUT') ? STDOUT : @fopen('php://stdout', 'w')) ?: null; + } + + /** @return resource|null */ + private function errStream() + { + return $this->err ??= (defined('STDERR') ? STDERR : @fopen('php://stderr', 'w')) ?: null; + } +} diff --git a/plugins/Logger/Provider.php b/plugins/Logger/Provider.php new file mode 100644 index 0000000..38d344b --- /dev/null +++ b/plugins/Logger/Provider.php @@ -0,0 +1,95 @@ +withPorts([...])); this Provider registers a config-driven + * fallback so LoggerPort always resolves rather than silently being absent. + * + * That fallback matters more than usual here. The failure this plugin exists to + * fix was not "logging is hard to configure" — it was that logging LOOKED + * configured and went nowhere: the single binding of Psr\Log\LoggerInterface in + * the codebase pointed at a null logger, so Database, Tenancy and EventBus all + * wrote into a black hole. A resolvable, file-backed default is what makes that + * failure mode impossible to reach by accident. + */ +final class Provider implements ModuleContract +{ + public function solves(): string + { + return 'logging.application'; + } + + /** @return list */ + public function requires(): array + { + return []; + } + + /** @return list */ + public function exposes(): array + { + return [LoggerPort::class]; + } + + public function register(ModuleContainer $container): void + { + if ($container->has(LoggerPort::class)) { + return; // the project wired it in withPorts() — respect that + } + + $container->bind(LoggerPort::class, static fn(): LoggerPort => self::fromConfig()); + } + + /** + * Build the adapter named by config/logger.php. + * + * Public + static so a project bootstrap can reuse the same resolution in + * withPorts() without duplicating the channel switch. + */ + public static function fromConfig(): LoggerPort + { + $config = \function_exists('config') ? config('logger', []) : []; + $channel = \is_array($config) ? (string) ($config['channel'] ?? 'file') : 'file'; + $level = LogLevel::parse(\is_array($config) ? (string) ($config['level'] ?? 'debug') : 'debug'); + + return match ($channel) { + 'null' => new NullLogger(), + 'stream' => new StreamLogger(minimum: $level), + default => new FileLogger(self::file($config), $level), + }; + } + + /** @param array|mixed $config */ + private static function file(mixed $config): string + { + $configured = \is_array($config) ? (string) ($config['file'] ?? '') : ''; + + // Separate from errors.log on purpose — that file belongs to the + // ErrorPipeline/ErrorGuard and should stay exception-only. + return $configured !== '' ? $configured : Paths::logs('app.log'); + } + + public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void + { + } +} diff --git a/plugins/Logger/config/logger.php b/plugins/Logger/config/logger.php new file mode 100644 index 0000000..e28071e --- /dev/null +++ b/plugins/Logger/config/logger.php @@ -0,0 +1,38 @@ +/config/logger.php over this file. + */ +return [ + /* + * Where records go. + * + * file append to LOG_FILE (default var/logs/app.log) + * stream stdout/stderr — the right choice in containers + * null discard. Only ever set this deliberately; see NullLogger's + * docblock for why a silently-null logger is a real hazard. + */ + 'channel' => env('LOG_CHANNEL', 'file'), + + /* + * Minimum severity to record. Anything less severe is dropped. + * emergency|alert|critical|error|warning|notice|info|debug + * + * 'debug' in development, 'info' or 'warning' in production. + */ + 'level' => env('LOG_LEVEL', 'debug'), + + /* + * File channel target. Defaults to the project's var/logs/app.log. + * + * Deliberately NOT errors.log: that file belongs to the ErrorPipeline and + * ErrorGuard, which record escaped Throwables. Mixing routine application + * logging into it would bury the exceptions it exists to surface. + */ + 'file' => env('LOG_FILE', ''), +]; diff --git a/plugins/Logger/module.json b/plugins/Logger/module.json new file mode 100644 index 0000000..a6fbf2c --- /dev/null +++ b/plugins/Logger/module.json @@ -0,0 +1,15 @@ +{ + "name": "logger", + "version": "1.0.0", + "solves": "logging.application", + "type": "module", + + "requires": [], + "exposes": ["AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\LoggerPort"], + + "config": [ + { "key": "LOG_CHANNEL", "type": "string", "required": false }, + { "key": "LOG_LEVEL", "type": "string", "required": false }, + { "key": "LOG_FILE", "type": "string", "required": false } + ] +} diff --git a/plugins/Mail/Provider.php b/plugins/Mail/Provider.php index 7a2f6d5..95f7f21 100644 --- a/plugins/Mail/Provider.php +++ b/plugins/Mail/Provider.php @@ -132,17 +132,30 @@ private function makeDkim(array $config): ?DkimSigner return new DkimSigner($domain, $selector, $key); } - /** @return array */ + /** + * Mail configuration, from the compiled config manifest. + * + * The manifest deep-merges this plugin's config/mail.php with the project's, + * so a project overriding one key (say mail.from.address) inherits every + * other default instead of having to copy the whole file — which is what the + * previous project-file-REPLACES-plugin-file lookup forced. + * + * Falls back to reading the shipped file directly when no manifest exists, + * so the plugin still works in a unit test that never ran the BootPipeline. + * + * @return array + */ private function config(): array { - $default = __DIR__ . '/config/mail.php'; - $path = function_exists('config_path') && is_file(config_path('mail.php')) - ? config_path('mail.php') - : $default; + $config = \function_exists('config') ? config('mail') : null; - /** @var array $config */ - $config = is_file($path) ? require $path : []; + if (\is_array($config) && $config !== []) { + return $config; + } + + /** @var array $fallback */ + $fallback = require __DIR__ . '/config/mail.php'; - return is_array($config) ? $config : []; + return \is_array($fallback) ? $fallback : []; } } diff --git a/plugins/RedisCache/Infrastructure/RedisCacheAdapter.php b/plugins/RedisCache/Infrastructure/RedisCacheAdapter.php index afe7e60..ac82745 100644 --- a/plugins/RedisCache/Infrastructure/RedisCacheAdapter.php +++ b/plugins/RedisCache/Infrastructure/RedisCacheAdapter.php @@ -5,6 +5,7 @@ namespace Plugins\RedisCache\Infrastructure; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\Lock; /** * Redis-backed CachePort adapter (GDA rewrite of the 0.3 Redis cache layer). @@ -116,4 +117,14 @@ public function flush(): bool $this->deletePattern('*'); return true; } + + public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock + { + return new RedisLock($this->connection, $name, $seconds, $owner); + } + + public function restoreLock(string $name, string $owner): Lock + { + return new RedisLock($this->connection, $name, 0, $owner); + } } diff --git a/plugins/RedisCache/Infrastructure/RedisLock.php b/plugins/RedisCache/Infrastructure/RedisLock.php new file mode 100644 index 0000000..d8420f3 --- /dev/null +++ b/plugins/RedisCache/Infrastructure/RedisLock.php @@ -0,0 +1,84 @@ + NX PX — atomic test-and-set in one round trip. + * RELEASE Lua compare-and-delete — see below; this is the important part. + * + * WHY RELEASE IS A LUA SCRIPT + * --------------------------- + * The obvious implementation is wrong: + * + * if ($redis->get($key) === $owner) { // (1) we still own it + * $redis->del($key); // (2) ...delete it + * } + * + * Between (1) and (2) our TTL can expire and ANOTHER worker can acquire the same + * lock. Our del() then deletes THEIR lock, and two workers run the critical + * section simultaneously — the exact failure the lock existed to prevent. It is + * rare, load-dependent, and effectively undebuggable in production. + * + * EVAL runs the compare and the delete as one atomic Redis operation, so the + * window does not exist. Never replace this with GET + DEL. + */ +final class RedisLock extends AbstractLock +{ + /** KEYS[1] = lock key, ARGV[1] = owner token. Returns 1 when released. */ + private const RELEASE_SCRIPT = <<<'LUA' + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) + end + return 0 + LUA; + + public function __construct( + private readonly RedisConnection $connection, + string $name, + int $seconds, + ?string $owner = null, + ) { + parent::__construct($name, $seconds, $owner ?? self::randomOwner()); + } + + public function acquire(): bool + { + $key = $this->key(); + $client = $this->connection->client(); + + // NX = only set when absent; PX = TTL in milliseconds. + // A zero/negative TTL means "hold until released" — supported, but the + // holder dying then strands the lock forever, so prefer a real TTL. + $options = $this->seconds > 0 + ? ['NX', 'PX' => $this->seconds * 1000] + : ['NX']; + + return (bool) $client->set($key, $this->owner, $options); + } + + public function release(): bool + { + return (int) $this->connection->client()->eval( + self::RELEASE_SCRIPT, + [$this->key(), $this->owner], + 1, // the first argument is a KEY, the rest are ARGV + ) === 1; + } + + public function forceRelease(): void + { + $this->connection->client()->del($this->key()); + } + + private function key(): string + { + return $this->connection->prefix('lock:' . $this->name); + } +} diff --git a/plugins/RedisCache/Infrastructure/RedisQueueAdapter.php b/plugins/RedisCache/Infrastructure/RedisQueueAdapter.php index c705fab..5003fa7 100644 --- a/plugins/RedisCache/Infrastructure/RedisQueueAdapter.php +++ b/plugins/RedisCache/Infrastructure/RedisQueueAdapter.php @@ -4,6 +4,7 @@ namespace Plugins\RedisCache\Infrastructure; +use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\JobPayload; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\QueuePort; /** @@ -61,6 +62,90 @@ public function promoteDue(string $queue = 'default'): int return $promoted; } + /** + * Reserve the next due job. + * + * Promotes any due delayed jobs first, then RPOPs one envelope. Non-blocking + * by contract — WorkerLoop owns the idle backoff, so BRPOP here would defeat + * both its stop() and its iteration cap. + */ + public function pop(string $queue = 'default'): ?JobPayload + { + $this->promoteDue($queue); + + $raw = $this->connection->client()->rPop($this->listKey($queue)); + if ($raw === false || !is_string($raw) || $raw === '') { + return null; + } + + $env = json_decode($raw, true); + if (!is_array($env)) { + return null; // unparseable envelope — drop rather than crash the worker + } + + return new JobPayload( + jobId: (string) ($env['id'] ?? ''), + jobClass: (string) ($env['jobClass'] ?? ''), + data: (array) ($env['payload'] ?? []), + queue: $queue, + attempts: (int) ($env['attempts'] ?? 0), + maxAttempts: (int) ($env['maxAttempts'] ?? 3), + enqueuedAt: new \DateTimeImmutable('@' . (int) ($env['enqueuedAt'] ?? time())), + signature: (string) ($env['signature'] ?? ''), + ); + } + + /** + * Nothing to do: pop() already removed the envelope from the list, so a + * completed job is gone. Kept explicit so the four-verb lifecycle reads the + * same across adapters. + */ + public function ack(JobPayload $payload): void + { + } + + /** Re-enqueue with an incremented attempt count, delayed by $delay seconds. */ + public function release(JobPayload $payload, int $delay = 0): void + { + $env = $this->envelope($payload, $payload->attempts() + 1); + + $client = $this->connection->client(); + if ($delay > 0) { + $client->zAdd($this->delayedKey($payload->queue()), time() + $delay, $env); + + return; + } + + $client->lPush($this->listKey($payload->queue()), $env); + } + + /** + * Move to the dead-letter list rather than dropping. A permanently-failing + * job that simply vanishes is the hardest kind of bug to notice. + */ + public function fail(JobPayload $payload, ?\Throwable $reason = null): void + { + $this->connection->client()->lPush( + $this->failedKey($payload->queue()), + $this->envelope($payload, $payload->attempts(), $reason?->getMessage()), + ); + } + + private function envelope(JobPayload $payload, int $attempts, ?string $error = null): string + { + return json_encode(array_filter([ + 'id' => $payload->jobId(), + 'jobClass' => $payload->jobClass(), + 'payload' => $payload->data(), + 'queue' => $payload->queue(), + 'attempts' => $attempts, + 'maxAttempts' => $payload->maxAttempts(), + 'enqueuedAt' => $payload->enqueuedAt()->getTimestamp(), + 'signature' => $payload->signature(), + 'error' => $error, + ], static fn($v): bool => $v !== null), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}'; + } + /** * @param array $payload */ @@ -68,9 +153,13 @@ private function dispatch(string $jobClass, array $payload, string $queue, ?int { $id = bin2hex(random_bytes(16)); $env = json_encode([ - 'id' => $id, - 'jobClass' => $jobClass, - 'payload' => $payload, + 'id' => $id, + 'jobClass' => $jobClass, + 'payload' => $payload, + 'queue' => $queue, + 'attempts' => 0, + 'maxAttempts' => 3, + 'enqueuedAt' => time(), ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}'; $client = $this->connection->client(); @@ -91,4 +180,9 @@ private function delayedKey(string $queue): string { return $this->connection->prefix('queue:' . $queue . ':delayed'); } + + private function failedKey(string $queue): string + { + return $this->connection->prefix('queue:' . $queue . ':failed'); + } } diff --git a/plugins/Storage/Support/helpers.php b/plugins/Storage/Support/helpers.php index 3b7e42f..ab7d6b4 100644 --- a/plugins/Storage/Support/helpers.php +++ b/plugins/Storage/Support/helpers.php @@ -2,48 +2,65 @@ declare(strict_types=1); -use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; - if (!function_exists('storage_config')) { /** - * Read the storage configuration (config/storage.php), env-driven and cached - * per process. A project copy at projects//config/storage.php wins over - * the plugin default. + * Read the storage configuration (config/storage.php). * * storage_config(); // full array * storage_config('driver'); // 'local' | 's3' * storage_config('s3.bucket'); // dotted access into a section * storage_config('local.root', '/tmp') // value, or fallback if absent * + * Backed by the compiled config manifest, so a project's + * projects//config/storage.php is DEEP-MERGED over the plugin default: + * overriding `s3.bucket` no longer discards the rest of the shipped config, + * which the previous project-file-replaces-plugin-file lookup did silently. + * * @return mixed the whole config array, or a single (dotted) key's value */ function storage_config(?string $key = null, mixed $default = null): mixed { - /** @var array|null $config */ - static $config = null; - - if ($config === null) { - $projectFile = Paths::config('storage.php'); - $pluginFile = __DIR__ . '/../config/storage.php'; + if ($key === null) { + $all = function_exists('config') ? config('storage') : null; - $file = is_file($projectFile) ? $projectFile : $pluginFile; - $loaded = require $file; - $config = is_array($loaded) ? $loaded : []; + return is_array($all) && $all !== [] ? $all : storage_config_fallback(); } - if ($key === null) { - return $config; + if (function_exists('config')) { + $value = config('storage.' . $key, $sentinel = new stdClass()); + if ($value !== $sentinel) { + return $value; + } } - // Dotted access: "s3.bucket" → $config['s3']['bucket']. - $value = $config; + // No manifest compiled (e.g. a unit test that never ran the BootPipeline). + $config = storage_config_fallback(); foreach (explode('.', $key) as $segment) { - if (!is_array($value) || !array_key_exists($segment, $value)) { + if (!is_array($config) || !array_key_exists($segment, $config)) { return $default; } - $value = $value[$segment]; + $config = $config[$segment]; + } + + return $config; + } +} + +if (!function_exists('storage_config_fallback')) { + /** + * The plugin's shipped defaults, used only when no config manifest exists. + * + * @return array + */ + function storage_config_fallback(): array + { + static $config = null; + + if ($config === null) { + $loaded = require __DIR__ . '/../config/storage.php'; + $config = is_array($loaded) ? $loaded : []; } - return $value; + return $config; } } diff --git a/plugins/Tenancy/Application/Services/AuditService.php b/plugins/Tenancy/Application/Services/AuditService.php index 0c05397..b8e2e06 100644 --- a/plugins/Tenancy/Application/Services/AuditService.php +++ b/plugins/Tenancy/Application/Services/AuditService.php @@ -6,8 +6,8 @@ use Plugins\Tenancy\Application\Ports\AuditSink; use Plugins\Tenancy\Application\Ports\AuditWriter; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; +use Plugins\Logger\Infrastructure\NullLogger; /** * AuditService — application service for the append-only central audit trail. @@ -22,7 +22,7 @@ final class AuditService implements AuditSink { public function __construct( private readonly AuditWriter $writer, - private readonly LoggerInterface $logger = new NullLogger(), + private readonly LoggerPort $logger = new NullLogger(), ) {} public function record( diff --git a/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php b/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php index e2f2cd0..7b03820 100644 --- a/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php +++ b/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php @@ -18,8 +18,8 @@ use Plugins\Tenancy\Domain\Exceptions\TenantUnavailableException; use Plugins\Tenancy\Domain\Exceptions\UnknownTenantException; use Plugins\Tenancy\Domain\ValueObjects\TenantStatus; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; +use Plugins\Logger\Infrastructure\NullLogger; /** * TenantConnectionResolver — maps tenant_id -> isolated DatabasePort. @@ -49,7 +49,7 @@ public function __construct( private readonly TenantRegistryContract $registry, private readonly EncryptionPort $crypto, private readonly CachePort $cache, - private readonly LoggerInterface $logger = new NullLogger(), + private readonly LoggerPort $logger = new NullLogger(), private readonly int $breakerThreshold = 5, private readonly int $breakerCooldown = 30, /** diff --git a/plugins/Tenancy/Provider.php b/plugins/Tenancy/Provider.php index 4faacea..62eaaac 100644 --- a/plugins/Tenancy/Provider.php +++ b/plugins/Tenancy/Provider.php @@ -53,8 +53,8 @@ use Plugins\Tenancy\Infrastructure\Persistence\TenantHostRepository; use Plugins\Tenancy\Infrastructure\Persistence\TenantRegistry; use Plugins\Tenancy\Infrastructure\TenantConnectionResolver; -use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; +use Plugins\Logger\Infrastructure\NullLogger; /** * Provider — wires the Tenancy control plane. @@ -365,12 +365,12 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke } } - private static function optionalLogger(mixed $container): LoggerInterface + private static function optionalLogger(mixed $container): LoggerPort { try { - $logger = $container->make(LoggerInterface::class); + $logger = $container->make(LoggerPort::class); - return $logger instanceof LoggerInterface ? $logger : new NullLogger(); + return $logger instanceof LoggerPort ? $logger : new NullLogger(); } catch (\Throwable) { return new NullLogger(); } diff --git a/plugins/Validation/Provider.php b/plugins/Validation/Provider.php index 8e3b6cb..d026fa6 100644 --- a/plugins/Validation/Provider.php +++ b/plugins/Validation/Provider.php @@ -62,16 +62,19 @@ public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worke /** @return array{rulesets?: list, groups?: array} */ private function config(): array { - // Prefer a project override (config_path helper) when available; fall - // back to the plugin's shipped default. - $default = __DIR__ . '/config/validation.php'; - $path = function_exists('config_path') && is_file(config_path('validation.php')) - ? config_path('validation.php') - : $default; + // From the compiled config manifest, where the project's config/validation.php + // is DEEP-MERGED over this plugin's — so a project adding one rule group + // keeps the shipped rulesets instead of replacing the file wholesale. + $config = \function_exists('config') ? config('validation') : null; - /** @var array $config */ - $config = is_file($path) ? require $path : []; + if (\is_array($config) && $config !== []) { + return $config; + } + + // No manifest (e.g. a unit test that never ran the BootPipeline). + /** @var array $fallback */ + $fallback = require __DIR__ . '/config/validation.php'; - return is_array($config) ? $config : []; + return \is_array($fallback) ? $fallback : []; } } From 53069b16175ce79bfebd63132aff71c0a75da7fd Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 6 Aug 2026 04:03:59 +0300 Subject: [PATCH 098/140] docs: reconcile drift and document the kernel additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FirewallLayer / RateLimiterLayer were referenced in six files but have never existed; the kernel ships only CsrfTokenLayer (IP filtering and rate limiting are SecurityFilters route filters). Following CLAUDE.md produced a bootstrap that fatals on an undefined class. - CoreContainer's docblock claimed build() freezes the container; Kernel.php freezes in materialize(). - CLAUDE.md documented src/Kernel/Http/, which moved to the alfacode-team/http package (namespace unchanged). - Worker template: drop the hand-written puller that type-checked its adapter and returned null for anything else — swapping to Redis made the worker silently process nothing. - New: docs/ai-context/28_KERNEL_ADDITIONS.md + migration analyses. --- src/System/GlobalKernelProjectScaffolder.php | 65 ++++++++++++++++++++ templates/app/bootstrap/app.php | 12 +++- templates/app/worker/run.php | 64 +++++++------------ 3 files changed, 97 insertions(+), 44 deletions(-) diff --git a/src/System/GlobalKernelProjectScaffolder.php b/src/System/GlobalKernelProjectScaffolder.php index 2f03dfb..36062f8 100644 --- a/src/System/GlobalKernelProjectScaffolder.php +++ b/src/System/GlobalKernelProjectScaffolder.php @@ -725,13 +725,18 @@ private function inMemoryCacheAdapter(): string namespace App\Infrastructure; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\AbstractLock; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\Lock; final class InMemoryCache implements CachePort { /** @var array */ private array $items = []; + /** @var array */ + private array $locks = []; + public function get(string $key): mixed { return $this->items[$key] ?? null; @@ -786,8 +791,68 @@ public function deletePattern(string $pattern): int public function flush(): bool { $this->items = []; + $this->locks = []; return true; } + + /** + * WARNING: process-local. Under PHP-FPM each request is a separate process, + * so two concurrent requests BOTH acquire this lock and both enter the + * critical section. Fine for tests and single-process CLI; for real + * single-flight/idempotency use a Redis-backed CachePort. + */ + public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock + { + return $this->makeLock($name, $seconds, $owner ?? bin2hex(random_bytes(16))); + } + + public function restoreLock(string $name, string $owner): Lock + { + return $this->makeLock($name, 0, $owner); + } + + private function makeLock(string $name, int $seconds, string $owner): Lock + { + return new class($this->locks, $name, $seconds, $owner) extends AbstractLock { + /** @param array $table */ + public function __construct( + private array &$table, + string $name, + int $seconds, + string $owner, + ) { + parent::__construct($name, $seconds, $owner); + } + + public function acquire(): bool + { + $held = $this->table[$this->name] ?? null; + if ($held !== null && ($held['expires'] === 0 || $held['expires'] >= time())) { + return false; + } + $this->table[$this->name] = [ + 'owner' => $this->owner, + 'expires' => $this->seconds > 0 ? time() + $this->seconds : 0, + ]; + return true; + } + + public function release(): bool + { + $held = $this->table[$this->name] ?? null; + if ($held === null || !hash_equals($this->owner, $held['owner'])) { + return false; + } + unset($this->table[$this->name]); + return true; + } + + public function forceRelease(): void + { + unset($this->table[$this->name]); + } + }; + } } PHP; } diff --git a/templates/app/bootstrap/app.php b/templates/app/bootstrap/app.php index bd565dd..76dff05 100644 --- a/templates/app/bootstrap/app.php +++ b/templates/app/bootstrap/app.php @@ -82,6 +82,7 @@ // Plugins — module providers (registered into the kernel below). use Plugins\Crypto\Provider as CryptoProvider; +use Plugins\Logger\Provider as LoggerProvider; use Plugins\I18n\Provider as I18nProvider; use Plugins\Database\Provider as DatabaseProvider; use Plugins\Commands\Provider as CommandsProvider; @@ -258,7 +259,9 @@ // (WordPress-nonce style): the token is signed with APP_KEY and bound to the // opaque `csrf_bind` cookie, so no cookie VALUE is ever trusted as the token. // /api is exempt because APIs authenticate per request, not via a browser - // CSRF token. Add a FirewallLayer / RateLimiterLayer here as needed. + // CSRF token — the only security layer the kernel ships. For IP filtering + // and rate limiting, declare the 'shield' / 'throttle' route filters from + // plugins/SecurityFilters on the routes that need them. ->withSecurity([ new CsrfTokenLayer( bindCookie: 'csrf_bind', @@ -271,6 +274,13 @@ // in. Use for capabilities only SOME routes need (views, outbound HTTP, // storage). A route opts in via its "requires" in proj.json / module.json. ->withModules([ + // Logger (solves: logging.application) — supplies the LoggerPort adapter. + // Channel/level come from config/logger.php (LOG_CHANNEL, LOG_LEVEL, + // LOG_FILE). Keep this registered: components that log (Database, + // Tenancy, EventBus, command auditing) degrade to silence without it, + // and silent logging is indistinguishable from nothing having happened. + LoggerProvider::class, + // Crypto (solves: crypto.services) — provides the concrete AesEncrypter and // PasswordHasher classes behind the Encryption/Hashing port factories, // plus crypto helpers other modules consume. diff --git a/templates/app/worker/run.php b/templates/app/worker/run.php index 400ccb0..7f1ff14 100644 --- a/templates/app/worker/run.php +++ b/templates/app/worker/run.php @@ -16,11 +16,11 @@ * bootstrap/app.php, which loads .env and installs the error net), then drives * the kernel's WorkerLoop. The loop repeatedly: * - * 1. calls $puller() to fetch the next job (returns null when the queue is empty), - * 2. rebuilds it into a JobPayload, - * 3. resolves the job handler inside its OWNING module's scope (full DI), and - * 4. runs handle(); on success it acknowledges, on a thrown error it retries - * per the job's retry strategy, calling failed() after the last attempt. + * 1. pops the next JobPayload off the bound QueuePort (null = queue empty), + * 2. resolves the job handler inside its OWNING module's scope (full DI), + * 3. runs handle(), then ACKs it, and + * 4. on a thrown error releases it for retry with backoff — or, once attempts + * are exhausted, calls failed() and dead-letters it. * * How jobs get ENQUEUED: application code pushes them via QueuePort::push(...) * (e.g. the SEO module enqueues 'seo.indexnow'). This process is the consumer. @@ -45,9 +45,7 @@ psp_require_kernel_autoload(); use AlfacodeTeam\PhpServicePlatform\Kernel\Kernel; -use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\JobPayload; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\QueuePort; -use Project\Infrastructure\FileQueue; // 2. Build the application — same Kernel object the web and CLI entries use. /** @var Kernel $kernel */ @@ -57,41 +55,19 @@ $queue = (string) (getenv('WORKER_QUEUE') ?: 'default'); $maxIterations = (int) (getenv('WORKER_MAX_ITERATIONS') ?: 0); -// 4. Resolve the active QueuePort. This is whatever bootstrap/app.php bound — -// FileQueue by default, or the Redis-backed adapter when RedisCache is active. -// (Swapping the backend needs no change here: only the FileQueue->pop() shape -// below is backend-specific; a Redis adapter would BLPOP instead.) -$queueAdapter = $kernel->container()->make(QueuePort::class); - -// 5. The $puller: the loop calls this to fetch the next job. Returning null means -// "nothing to do right now" and the loop idles/backs off. Here we pop one raw -// record off the file queue and rehydrate it into a typed JobPayload. -$puller = static function () use ($queue, $queueAdapter): ?JobPayload { - if (!$queueAdapter instanceof FileQueue) { - return null; // unknown backend — stay idle rather than guess its API - } - - $record = $queueAdapter->pop($queue); - if ($record === null) { - return null; // queue empty - } - - return new JobPayload( - jobId: (string) $record['jobId'], - jobClass: (string) $record['jobClass'], - data: (array) $record['data'], - queue: (string) $record['queue'], - attempts: (int) $record['attempts'], - maxAttempts: (int) $record['maxAttempts'], - enqueuedAt: new \DateTimeImmutable((string) $record['enqueuedAt']), - signature: '', - ); -}; - -// 6. The kernel's worker loop — materialises the Worker pipeline on first call. +// 4. The kernel's worker loop — materialises the Worker pipeline on first call. +// +// There is no $puller to write. The loop resolves the bound QueuePort itself +// and owns the whole lifecycle: pop → handle → ack on success, release with +// backoff on a retryable failure, fail (dead-letter) once attempts run out. +// +// Swapping the backend — FileQueue, Redis, anything else — needs no change +// here. (This file used to carry a hand-written puller that type-checked the +// adapter and returned null for anything it did not recognise, so switching +// to Redis made the worker silently process nothing at all.) $loop = $kernel->workerLoop(); -// 7. Graceful shutdown. With pcntl available, trap SIGTERM/SIGINT and ask the +// 5. Graceful shutdown. With pcntl available, trap SIGTERM/SIGINT and ask the // loop to stop AFTER the in-flight job completes (no partial processing). if (function_exists('pcntl_signal')) { pcntl_async_signals(true); @@ -106,7 +82,9 @@ echo "[{{PROJECT_NAME}}] Worker loop started queue={$queue}" . ($maxIterations > 0 ? " maxIterations={$maxIterations}" : ' (forever)') . "\n"; -// 8. Run until stopped (signal) or until maxIterations jobs have been processed. -$loop->run($puller, $maxIterations); +// 6. Run until stopped (signal) or until maxIterations jobs have been processed. +// Port mode: no puller. Drains $queue until stop() or the iteration cap. +$loop->run(maxIterations: $maxIterations, queue: $queue); -echo "[{{PROJECT_NAME}}] Worker finished. Remaining in '{$queue}': " . $queueAdapter->size($queue) . "\n"; +echo "[{{PROJECT_NAME}}] Worker finished. Remaining in '{$queue}': " + . $kernel->container()->make(QueuePort::class)->size($queue) . "\n"; From b16e0c37dc5174ee0e65a22d611057444575863b Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 6 Aug 2026 04:04:13 +0300 Subject: [PATCH 099/140] fix(tests): repair three OAuth2 fakes drifted from their interfaces InMemoryRefreshTokenStore lacked allActive(), InMemoryScopeStore lacked put()/delete(), and an anonymous RefreshTokenStore in AuthorizedTokenManagementTest lacked allActive(). Each fatals at COLLECTION time, so the whole suite aborted with no output rather than failing one file. Also: FakeCache implements the new CachePort lock methods, and the Zig bootstrap template drops its FirewallLayer/RateLimiterLayer reference. --- .../OAuth2/AuthorizedTokenManagementTest.php | 4 ++ tests/Unit/Plugins/OAuth2/OAuth2FlowTest.php | 39 ++++++++++++++++--- .../Unit/Plugins/OAuth2/ScopeRegistryTest.php | 10 +++++ tests/Unit/Plugins/User/Support/FakeCache.php | 22 ++++++++++- tools/src/templates/app/bootstrap/app.php | 4 +- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/tests/Unit/Plugins/OAuth2/AuthorizedTokenManagementTest.php b/tests/Unit/Plugins/OAuth2/AuthorizedTokenManagementTest.php index 5efb291..0de5b35 100644 --- a/tests/Unit/Plugins/OAuth2/AuthorizedTokenManagementTest.php +++ b/tests/Unit/Plugins/OAuth2/AuthorizedTokenManagementTest.php @@ -28,6 +28,10 @@ public function findByUser(string $userId): array { return array_values(array_filter($this->tokens, static fn(RefreshToken $t) => $t->userId === $userId)); } + public function allActive(): array + { + return array_values(array_filter($this->tokens, static fn(RefreshToken $t) => !$t->isExpired())); + } public function revokeIfActive(string $tokenId): bool { return true; } public function revokeFamily(string $familyId): int { $this->revokedFamilies[] = $familyId; return 1; } public function deleteExpired(?\DateTimeImmutable $now = null): int { return 0; } diff --git a/tests/Unit/Plugins/OAuth2/OAuth2FlowTest.php b/tests/Unit/Plugins/OAuth2/OAuth2FlowTest.php index 12b173b..7e9a74f 100644 --- a/tests/Unit/Plugins/OAuth2/OAuth2FlowTest.php +++ b/tests/Unit/Plugins/OAuth2/OAuth2FlowTest.php @@ -450,6 +450,19 @@ public function findByUser(string $userId): array return $out; } + public function allActive(): array + { + $out = []; + foreach ($this->byHash as $row) { + $t = $row['token']; + if (!$row['revoked'] && !$t->isExpired()) { + $out[] = $t; + } + } + + return $out; + } + public function revokeIfActive(string $tokenId): bool { $hash = $this->idToHash[$tokenId] ?? null; @@ -479,18 +492,34 @@ public function deleteExpired(?\DateTimeImmutable $now = null): int { return 0; final class InMemoryScopeStore implements ScopeStore { + /** @var array id => description */ + private array $catalogue; + /** @param list $scopes */ - public function __construct(private array $scopes) + public function __construct(array $scopes) { + $this->catalogue = array_fill_keys($scopes, ''); } - public function exists(string $scope): bool { return in_array($scope, $this->scopes, true); } + public function exists(string $scope): bool { return array_key_exists($scope, $this->catalogue); } + + public function all(): array { return array_keys($this->catalogue); } - public function all(): array { return $this->scopes; } + public function describe(): array { return $this->catalogue; } + + public function put(string $id, string $description): void + { + $this->catalogue[$id] = $description; + } - public function describe(): array + public function delete(string $id): bool { - return array_fill_keys($this->scopes, ''); + if (!array_key_exists($id, $this->catalogue)) { + return false; + } + unset($this->catalogue[$id]); + + return true; } } diff --git a/tests/Unit/Plugins/OAuth2/ScopeRegistryTest.php b/tests/Unit/Plugins/OAuth2/ScopeRegistryTest.php index 687b0f1..02c286a 100644 --- a/tests/Unit/Plugins/OAuth2/ScopeRegistryTest.php +++ b/tests/Unit/Plugins/OAuth2/ScopeRegistryTest.php @@ -19,6 +19,16 @@ private function registry(): ScopeRegistry public function exists(string $scope): bool { return array_key_exists($scope, $this->map); } public function all(): array { return array_keys($this->map); } public function describe(): array { return $this->map; } + public function put(string $id, string $description): void { $this->map[$id] = $description; } + public function delete(string $id): bool + { + if (!array_key_exists($id, $this->map)) { + return false; + } + unset($this->map[$id]); + + return true; + } }; return new ScopeRegistry($store); diff --git a/tests/Unit/Plugins/User/Support/FakeCache.php b/tests/Unit/Plugins/User/Support/FakeCache.php index 60628b1..6f14ffe 100644 --- a/tests/Unit/Plugins/User/Support/FakeCache.php +++ b/tests/Unit/Plugins/User/Support/FakeCache.php @@ -5,6 +5,8 @@ namespace Tests\Unit\Plugins\User\Support; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\Lock; +use Project\Infrastructure\ProcessLocalLock; /** In-memory CachePort for lockout tests. */ final class FakeCache implements CachePort @@ -29,5 +31,23 @@ public function increment(string $key, int $by = 1): int } public function deletePattern(string $pattern): int { return 0; } - public function flush(): bool { $this->store = []; return true; } + public function flush(): bool { $this->store = []; $this->locks = []; return true; } + + /** + * Lock table shared with every ProcessLocalLock handed out — single-process + * only, which is exactly what a test needs. + * + * @var array + */ + public array $locks = []; + + public function lock(string $name, int $seconds = 0, ?string $owner = null): Lock + { + return new ProcessLocalLock($this, $name, $seconds, $owner); + } + + public function restoreLock(string $name, string $owner): Lock + { + return new ProcessLocalLock($this, $name, 0, $owner); + } } diff --git a/tools/src/templates/app/bootstrap/app.php b/tools/src/templates/app/bootstrap/app.php index 11ce79e..3a4ce5c 100644 --- a/tools/src/templates/app/bootstrap/app.php +++ b/tools/src/templates/app/bootstrap/app.php @@ -240,7 +240,9 @@ // (WordPress-nonce style): the token is signed with APP_KEY and bound to the // opaque `csrf_bind` cookie, so no cookie VALUE is ever trusted as the token. // /api is exempt because APIs authenticate per request, not via a browser - // CSRF token. Add a FirewallLayer / RateLimiterLayer here as needed. + // CSRF token — the only security layer the kernel ships. For IP filtering + // and rate limiting, declare the 'shield' / 'throttle' route filters from + // plugins/SecurityFilters on the routes that need them. ->withSecurity([ new CsrfTokenLayer( bindCookie: 'csrf_bind', From 8e2db54a8a638dba88c8b78c0b14e337237195b9 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 6 Aug 2026 04:19:24 +0300 Subject: [PATCH 100/140] refactor!: decouple plugins from the kernel into standalone packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: plugins/ no longer exists in this repo. All 28 plugins are now composer packages (alfacode-team/hkm-plugin-*) with their own repositories, resolved from a sibling ../plugins workspace via a path repository with symlink:true, so edits there stay live without a reinstall. - composer.json: drop the "Plugins\\" PSR-4 root and the six plugin helper files[] entries; add the path repository and 28 requires. - Each plugin package now declares its own autoload.files. Three of them (Storage, ViteManifest, SocialAuth) defined global helpers that were in NO autoload.files at all — not the plugin's, not the kernel's — so callers such as Storage\Provider::register(), which calls storage_config() ten times, would fatal on an undefined function. Pre-existing; surfaced by the move. - Tests: resolve plugin asset paths via ReflectionClass instead of a repo-relative plugins/ path, which breaks once a plugin is a dependency. Suite unchanged at parity: 659 tests, 77 errors (ext-sqlite3 absent), 5 failures (php-io-cli submodule). --- composer.json | 48 +- composer.lock | 1221 ++++++++++++++-- .../API/Contracts/AuditReaderContract.php | 44 - .../API/Contracts/AuditServiceContract.php | 34 - .../Audit/Application/Ports/AuditWriter.php | 27 - .../Application/Services/AuditService.php | 84 -- plugins/Audit/Domain/Entities/AuditEntry.php | 57 - .../Persistence/AuditLogRepository.php | 123 -- .../Infrastructure/Persistence/AuditTrail.php | 65 - plugins/Audit/Provider.php | 94 -- plugins/Audit/database/migrations/.gitkeep | 0 ...26_06_22_000005_create_audit_log_table.php | 47 - plugins/Audit/module.json | 17 - .../API/Contracts/AuthServiceContract.php | 123 -- .../Contracts/RefreshTokenServiceContract.php | 41 - .../API/DTOs/PersonalAccessTokenResult.php | 39 - plugins/Auth/API/DTOs/RefreshRotation.php | 38 - plugins/Auth/API/DTOs/RefreshTokenIssued.php | 24 - plugins/Auth/API/DTOs/TokenDTO.php | 91 -- plugins/Auth/API/Guard.php | 135 -- plugins/Auth/API/ScopeInheritance.php | 56 - plugins/Auth/AUTH_GUIDE.pdf | Bin 236622 -> 0 bytes plugins/Auth/Application/Auth/AuthManager.php | 320 ----- .../Auth/Application/Auth/AuthUserProxy.php | 200 --- .../Auth/Application/Auth/GuardAccessor.php | 109 -- .../Auth/Application/Auth/GuardBehaviour.php | 78 - .../Application/Auth/ModelUserProvider.php | 99 -- .../Application/Auth/PasswordResetBroker.php | 179 --- .../Auth/PersonalAccessTokenFactory.php | 38 - .../Auth/Application/Auth/RoleResolver.php | 42 - .../Application/Auth/StatefulSessionGuard.php | 345 ----- .../Application/Ports/Authenticatable.php | 41 - plugins/Auth/Application/Ports/Driver.php | 66 - .../Auth/Application/Ports/GuardContext.php | 20 - .../Auth/Application/Ports/GuardDriver.php | 22 - .../Auth/Application/Ports/PasswordBroker.php | 59 - .../Application/Ports/RefreshTokenStore.php | 49 - .../Auth/Application/Ports/StatefulGuard.php | 49 - .../Application/Ports/SupportsBasicAuth.php | 21 - .../Auth/Application/Ports/UserProvider.php | 36 - .../Auth/Application/Services/AuthService.php | 301 ---- .../Services/DeviceSessionService.php | 283 ---- .../Services/MobileAuthService.php | 126 -- .../Services/RefreshTokenService.php | 158 -- .../Domain/Entities/PersonalAccessToken.php | 95 -- .../Domain/Entities/RefreshTokenRecord.php | 58 - .../Exceptions/AuthenticationException.php | 39 - .../Exceptions/AuthorizationException.php | 50 - .../Exceptions/InvalidAuthTokenException.php | 27 - .../InvalidRefreshTokenException.php | 26 - .../Exceptions/MissingScopeException.php | 30 - plugins/Auth/Domain/ValueObjects/Recaller.php | 54 - .../Infrastructure/Auth/Drivers/JwtDriver.php | 26 - .../Auth/Drivers/RequestDriver.php | 31 - .../Auth/Drivers/ResolvesFromVerdict.php | 41 - .../Auth/Drivers/SessionDriver.php | 58 - .../Auth/Drivers/TokenDriver.php | 26 - .../Cli/PruneAccessTokensCommand.php | 70 - .../Http/Controllers/AuthTokenController.php | 53 - .../Http/Controllers/MobileAuthController.php | 139 -- .../Controllers/PasswordResetController.php | 176 --- .../PersonalAccessTokenController.php | 87 -- .../Controllers/SessionAuthController.php | 219 --- .../Controllers/TransientTokenController.php | 61 - .../Http/Stages/SessionAuthStage.php | 211 --- .../Persistence/DeviceSessionRepository.php | 189 --- .../PersonalAccessTokenRepository.php | 186 --- .../Persistence/RefreshTokenRepository.php | 144 -- plugins/Auth/Provider.php | 366 ----- plugins/Auth/README.md | 611 -------- plugins/Auth/Security/JwtAuthLayer.php | 150 -- .../Security/PersonalAccessTokenLayer.php | 77 - plugins/Auth/Support/Token.php | 44 - plugins/Auth/Support/helpers.php | 47 - plugins/Auth/config/auth.php | 80 -- plugins/Auth/database/migrations/.gitkeep | 0 ...01_create_personal_access_tokens_table.php | 27 - ...nd_abilities_to_personal_access_tokens.php | 45 - ..._04_000002_create_refresh_tokens_table.php | 60 - ...7_12_000001_create_auth_sessions_table.php | 55 - plugins/Auth/module.json | 61 - .../Auth/resources/views/password-changed.php | 49 - plugins/Auth/resources/views/password-otp.php | 29 - .../AuthorizationServiceContract.php | 63 - .../Services/AuthorizationService.php | 95 -- .../Authorization/Engine/CachedEnforcer.php | 261 ---- plugins/Authorization/Engine/Config.php | 264 ---- plugins/Authorization/Engine/Constants.php | 26 - plugins/Authorization/Engine/CoreEnforcer.php | 1105 -------------- .../Engine/Effector/DefaultEffector.php | 112 -- .../Engine/Effector/Effector.php | 30 - .../Authorization/Engine/EnforceContext.php | 53 - plugins/Authorization/Engine/Enforcer.php | 874 ----------- .../Exceptions/BatchOperationException.php | 14 - .../Exceptions/CannotSaveFilteredPolicy.php | 14 - .../Engine/Exceptions/CasbinException.php | 14 - .../Exceptions/EmptyConditionException.php | 14 - .../Exceptions/EvalFunctionException.php | 11 - .../Exceptions/InvalidFilePathException.php | 14 - .../Exceptions/InvalidFilterTypeException.php | 14 - .../Exceptions/NotImplementedException.php | 14 - .../Exceptions/ObjConditionException.php | 14 - .../Engine/Interfaces/CacheableParam.php | 20 - .../Interfaces/ConditionalRoleManager.php | 89 -- .../Engine/Interfaces/Config.php | 53 - .../Engine/Interfaces/Logger.php | 76 - .../Engine/Interfaces/Persist/Adapter.php | 59 - .../Interfaces/Persist/BatchAdapter.php | 32 - .../Interfaces/Persist/FilteredAdapter.php | 31 - .../Interfaces/Persist/UpdatableAdapter.php | 47 - .../Engine/Interfaces/Persist/Watcher.php | 36 - .../Engine/Interfaces/Persist/WatcherEx.php | 59 - .../Interfaces/Persist/WatcherUpdatable.php | 34 - .../Engine/Interfaces/RoleManager.php | 190 --- .../Interfaces/Supports/AccessPermission.php | 171 --- .../Authorization/Engine/InternalEnforcer.php | 533 ------- plugins/Authorization/Engine/Log/Log.php | 109 -- .../Engine/Log/Logger/DefaultLogger.php | 205 --- .../Engine/ManagementEnforcer.php | 775 ---------- .../Authorization/Engine/Model/Assertion.php | 240 ---- .../Engine/Model/FunctionMap.php | 62 - plugins/Authorization/Engine/Model/Model.php | 389 ----- plugins/Authorization/Engine/Model/Policy.php | 667 --------- .../Engine/Persist/AdapterHelper.php | 69 - .../Engine/Persist/Adapters/FileAdapter.php | 235 --- .../Persist/Adapters/FileFilteredAdapter.php | 179 --- .../Engine/Persist/Adapters/Filter.php | 40 - .../Engine/RBAC/ConditionalDomainManager.php | 180 --- .../Engine/RBAC/ConditionalRoleManager.php | 295 ---- .../Engine/RBAC/DomainManager.php | 33 - plugins/Authorization/Engine/RBAC/Role.php | 312 ---- .../Authorization/Engine/RBAC/RoleManager.php | 46 - .../Engine/RBAC/Supports/BaseManager.php | 141 -- .../Engine/RBAC/Supports/DomainManager.php | 313 ---- .../Engine/RBAC/Supports/RoleManager.php | 385 ----- .../Engine/Util/BuiltinOperations.php | 527 ------- plugins/Authorization/Engine/functions.php | 78 - .../Infrastructure/Cli/SeedPolicyCommand.php | 100 -- .../Http/Stages/PolicyFilterStage.php | 63 - .../Persistence/DatabasePolicyAdapter.php | 200 --- plugins/Authorization/Provider.php | 108 -- plugins/Authorization/config/policy.seed.csv | 175 --- plugins/Authorization/config/rbac_model.conf | 17 - .../database/migrations/.gitkeep | 0 ..._06_05_000001_create_casbin_rule_table.php | 29 - plugins/Authorization/module.json | 18 - .../Contracts/MigrationServiceContract.php | 49 - .../ModuleManagementServiceContract.php | 28 - plugins/Commands/API/DTOs/MigrateRequest.php | 29 - plugins/Commands/API/DTOs/MigrateResponse.php | 48 - .../API/DTOs/MigrateStatusRequest.php | 21 - .../API/DTOs/MigrateStatusResponse.php | 35 - .../Commands/API/DTOs/ModuleAddRequest.php | 69 - .../Commands/API/DTOs/ModuleAddResponse.php | 52 - .../Commands/API/DTOs/ModuleRemoveRequest.php | 40 - .../API/DTOs/ModuleRemoveResponse.php | 44 - .../CommandsInfrastructureService.php | 287 ---- .../Application/Services/MigrationService.php | 248 ---- .../Services/ModuleManagementService.php | 89 -- .../Approval/MigrationApprovalManager.php | 128 -- plugins/Commands/Backup/BackupManager.php | 193 --- .../Configuration/ConfigurationValidator.php | 76 - .../EnvironmentConfigurationLoader.php | 57 - .../Deployment/DeploymentLockManager.php | 84 -- .../Deployment/DeploymentLockedException.php | 33 - .../Exceptions/ConfigurationException.php | 65 - .../Commands/Exceptions/ServiceException.php | 36 - .../Gateways/LetMigrateGateway.php | 84 -- .../Infrastructure/Gateways/ShellGateway.php | 137 -- .../Http/Commands/ModuleAddCommand.php | 95 -- .../Http/Commands/ModuleRemoveCommand.php | 91 -- .../Http/Commands/RouteListCommand.php | 210 --- .../Persistence/ApprovalRepository.php | 158 -- .../Persistence/BackupRepository.php | 94 -- .../Persistence/CommandAuditLogRepository.php | 138 -- .../Persistence/DeploymentLockRepository.php | 102 -- .../Persistence/MigrationRepository.php | 199 --- .../Persistence/ModuleRepository.php | 241 ---- .../Logging/CommandExecutionLogger.php | 92 -- plugins/Commands/Provider.php | 309 ---- plugins/Commands/Secrets/SecretsManager.php | 159 -- .../Validation/PreFlightValidator.php | 107 -- plugins/Commands/module.json | 41 - plugins/Cookie/Infrastructure/CookieJar.php | 142 -- .../Http/QueuedCookiesStage.php | 34 - plugins/Cookie/Provider.php | 67 - plugins/Cookie/Support/helpers.php | 78 - plugins/Cookie/config/cookie.php | 88 -- plugins/Cookie/module.json | 17 - .../Crypto/Infrastructure/AesEncrypter.php | 131 -- .../Crypto/Infrastructure/PasswordHasher.php | 59 - plugins/Crypto/Provider.php | 68 - plugins/Crypto/module.json | 22 - .../DatabaseConfigurationContract.php | 53 - .../DatabaseConnectionManagerContract.php | 51 - .../Exceptions/ConnectionException.php | 110 -- .../Drivers/DatabaseConfigurationFactory.php | 152 -- .../Drivers/MySQLConfiguration.php | 98 -- .../Drivers/PostgreSQLConfiguration.php | 76 - .../Drivers/SQLiteConfiguration.php | 76 - .../Drivers/SqlServerConfiguration.php | 86 -- .../Persistence/ConnectionManager.php | 88 -- .../MultiDriverDatabaseAdapter.php | 479 ------- .../Persistence/PooledDatabaseAdapter.php | 116 -- .../Persistence/SavepointGrammar.php | 65 - .../Infrastructure/Pool/ConnectionPool.php | 244 ---- .../Infrastructure/Pool/PoolConfiguration.php | 74 - .../Infrastructure/Pool/PooledConnection.php | 40 - plugins/Database/Provider.php | 157 -- plugins/Database/module.json | 39 - .../DevTools/Commands/ConfigClearCommand.php | 53 - .../DevTools/Commands/ConfigShowCommand.php | 136 -- .../DevTools/Commands/GeneratorCommand.php | 70 - .../DevTools/Commands/MakePluginCommand.php | 360 ----- .../DevTools/Commands/MakeServiceCommand.php | 64 - .../DevTools/Commands/ModuleInfoCommand.php | 109 -- .../DevTools/Commands/ModuleListCommand.php | 87 -- .../DevTools/Commands/ProjectListCommand.php | 108 -- .../DevTools/Commands/RoutesListCommand.php | 115 -- plugins/DevTools/Provider.php | 63 - plugins/DevTools/module.json | 15 - .../API/Contracts/EdgeServiceContract.php | 76 - plugins/Edge/Application/EdgeService.php | 304 ---- plugins/Edge/Domain/CacheProfile.php | 55 - plugins/Edge/Domain/EdgePlan.php | 29 - plugins/Edge/Domain/ServeModel.php | 62 - plugins/Edge/Domain/ServerStack.php | 91 -- plugins/Edge/Domain/Site.php | 57 - plugins/Edge/Domain/Strategy.php | 35 - plugins/Edge/Domain/SwooleOptions.php | 60 - plugins/Edge/Domain/TlsConfig.php | 26 - plugins/Edge/Domain/TlsMode.php | 40 - .../Infrastructure/Cli/EdgeApplyCommand.php | 258 ---- .../Infrastructure/Cli/EdgeHostsCommand.php | 71 - .../Infrastructure/Cli/EdgeServiceCommand.php | 135 -- .../Infrastructure/Cli/EdgeStatusCommand.php | 82 -- .../Edge/Infrastructure/ConfigRenderer.php | 1277 ----------------- .../Edge/Infrastructure/HostsFileWriter.php | 196 --- .../Edge/Infrastructure/ServiceRenderer.php | 168 --- plugins/Edge/Infrastructure/SiteCollector.php | 311 ---- .../Infrastructure/StreamConfigWriter.php | 219 --- plugins/Edge/Infrastructure/SystemProbe.php | 262 ---- plugins/Edge/Provider.php | 83 -- plugins/Edge/README.md | 267 ---- plugins/Edge/Support/helpers.php | 66 - plugins/Edge/USAGE.md | 362 ----- plugins/Edge/config/edge.php | 362 ----- plugins/Edge/module.json | 84 -- plugins/Feedback/API/DTOs/FeedbackPage.php | 40 - .../Feedback/API/DTOs/ListFeedbackQuery.php | 46 - .../Feedback/API/DTOs/SubmitFeedbackDTO.php | 60 - .../FeedbackSubmittedIntegrationEvent.php | 50 - .../Application/Ports/FeedbackStore.php | 30 - .../Application/Services/FeedbackService.php | 181 --- .../Domain/Entities/FeedbackEntry.php | 102 -- .../Domain/ValueObjects/FeedbackCategory.php | 38 - .../Domain/ValueObjects/FeedbackId.php | 43 - .../Domain/ValueObjects/FeedbackMessage.php | 43 - .../Domain/ValueObjects/FeedbackRating.php | 59 - .../Domain/ValueObjects/FeedbackStatus.php | 39 - plugins/Feedback/Domain/ValueObjects/Ulid.php | 57 - .../Http/Controllers/FeedbackController.php | 57 - .../Persistence/FeedbackRepository.php | 148 -- plugins/Feedback/Provider.php | 74 - plugins/Feedback/README.md | 49 - plugins/Feedback/database/migrations/.gitkeep | 0 ...6_29_000005_create_user_feedback_table.php | 55 - plugins/Feedback/module.json | 56 - .../Infrastructure/CurlHttpClient.php | 327 ----- .../Infrastructure/PendingRequest.php | 211 --- plugins/HttpClient/Provider.php | 63 - plugins/HttpClient/module.json | 20 - .../I18n/Infrastructure/Http/LocaleStage.php | 60 - plugins/I18n/Provider.php | 59 - plugins/I18n/Support/Lang.php | 38 - plugins/I18n/Support/helpers.php | 114 -- plugins/I18n/Translator.php | 241 ---- plugins/I18n/lang/en/validation.php | 26 - plugins/I18n/module.json | 20 - .../Logger/Infrastructure/AbstractLogger.php | 102 -- plugins/Logger/Infrastructure/FileLogger.php | 61 - plugins/Logger/Infrastructure/NullLogger.php | 33 - .../Logger/Infrastructure/PsrLoggerBridge.php | 38 - .../Logger/Infrastructure/StreamLogger.php | 64 - plugins/Logger/Provider.php | 95 -- plugins/Logger/config/logger.php | 38 - plugins/Logger/module.json | 15 - plugins/Mail/API/Contracts/MailerContract.php | 31 - plugins/Mail/Application/Jobs/SendMailJob.php | 45 - plugins/Mail/Application/Mailer.php | 169 --- plugins/Mail/Domain/Address.php | 54 - plugins/Mail/Domain/Attachment.php | 85 -- plugins/Mail/Domain/MailException.php | 10 - plugins/Mail/Domain/Message.php | 211 --- plugins/Mail/Domain/Priority.php | 22 - .../Http/MailDemoController.php | 225 --- .../Mail/Infrastructure/Mime/MimeBuilder.php | 275 ---- .../Infrastructure/Security/DkimSigner.php | 106 -- .../Transport/ArrayTransport.php | 42 - .../Infrastructure/Transport/LogTransport.php | 27 - .../Transport/MailTransport.php | 55 - .../Transport/SendmailTransport.php | 60 - .../Transport/SmtpTransport.php | 292 ---- .../Infrastructure/Transport/Transport.php | 22 - plugins/Mail/Provider.php | 161 --- plugins/Mail/README.md | 361 ----- plugins/Mail/config/mail.php | 49 - plugins/Mail/module.json | 53 - .../Application/Ports/AuthCodeStore.php | 22 - .../Application/Ports/AuthorizationFlow.php | 34 - .../OAuth2/Application/Ports/ClientStore.php | 53 - .../Application/Ports/DeviceCodeStore.php | 30 - .../Application/Ports/RefreshTokenStore.php | 38 - .../Ports/ResourceOwnerVerifier.php | 17 - .../OAuth2/Application/Ports/ScopeStore.php | 28 - .../Application/Ports/UserInfoProvider.php | 21 - .../Services/AuthorizationRequest.php | 44 - .../Services/AuthorizationService.php | 163 --- .../Application/Services/DeviceService.php | 89 -- .../Services/IntrospectionService.php | 110 -- .../Application/Services/ScopeRegistry.php | 85 -- .../Application/Services/ScopeValidator.php | 47 - .../Application/Services/TokenIssuer.php | 121 -- .../Application/Services/TokenService.php | 318 ---- plugins/OAuth2/Domain/Entities/AuthCode.php | 55 - plugins/OAuth2/Domain/Entities/Client.php | 105 -- plugins/OAuth2/Domain/Entities/DeviceCode.php | 57 - .../OAuth2/Domain/Entities/RefreshToken.php | 49 - .../Domain/Exceptions/OAuthException.php | 70 - .../OAuth2/Domain/ValueObjects/GrantType.php | 25 - plugins/OAuth2/Domain/ValueObjects/Pkce.php | 50 - .../Cli/Concerns/TargetsTenant.php | 59 - .../Cli/CreateClientCommand.php | 103 -- .../Infrastructure/Cli/ListClientsCommand.php | 67 - .../Infrastructure/Cli/PruneCommand.php | 73 - .../Cli/RevokeClientCommand.php | 57 - .../Cli/RotateClientSecretCommand.php | 67 - .../Infrastructure/Cli/TenantConnections.php | 106 -- .../Http/Concerns/ChecksOAuthAdmin.php | 37 - .../Http/Concerns/SpeaksOAuth.php | 57 - .../Http/Controllers/AdminController.php | 329 ----- .../Http/Controllers/AdminUiController.php | 64 - .../Controllers/AuthorizationController.php | 119 -- .../Controllers/AuthorizedTokenController.php | 68 - .../Http/Controllers/ClientController.php | 141 -- .../Http/Controllers/DeviceController.php | 41 - .../DeviceVerificationController.php | 78 - .../Http/Controllers/DiscoveryController.php | 70 - .../Controllers/IntrospectionController.php | 73 - .../Http/Controllers/JwksController.php | 101 -- .../Http/Controllers/ScopeController.php | 26 - .../Http/Controllers/TokenController.php | 40 - .../Http/Controllers/UserInfoController.php | 53 - .../Identity/SubjectUserInfoProvider.php | 20 - .../Identity/UserResourceOwnerVerifier.php | 28 - .../Persistence/AuthCodeRepository.php | 102 -- .../Persistence/ClientRepository.php | 169 --- .../Persistence/DeviceCodeRepository.php | 142 -- .../Persistence/RefreshTokenRepository.php | 156 -- .../Persistence/ScopeRepository.php | 80 -- plugins/OAuth2/OAUTH2_GUIDE.pdf | Bin 199459 -> 0 bytes plugins/OAuth2/Provider.php | 331 ----- plugins/OAuth2/README.md | 545 ------- plugins/OAuth2/database/migrations/.gitkeep | 0 ...6_27_000010_create_oauth_clients_table.php | 29 - ...7_000011_create_oauth_auth_codes_table.php | 34 - ...0012_create_oauth_refresh_tokens_table.php | 32 - ...06_27_000013_create_oauth_scopes_table.php | 23 - ...000014_create_oauth_device_codes_table.php | 33 - ...7_04_000001_add_owner_to_oauth_clients.php | 37 - plugins/OAuth2/module.json | 243 ---- plugins/OAuth2/resources/views/admin.php | 381 ----- plugins/OAuth2/resources/views/consent.php | 58 - plugins/OAuth2/resources/views/device.php | 52 - .../OAuth2/ui/admin/Pages/OAuth2/Admin.tsx | 518 ------- .../OAuth2/ui/admin/Pages/OAuth2/Consent.tsx | 61 - .../OAuth2/ui/admin/Pages/OAuth2/Simulate.tsx | 509 ------- plugins/OAuth2/ui/index.ts | 32 - plugins/OAuth2/ui/ui.json | 10 - .../API/Contracts/PageflowSharerContract.php | 22 - plugins/Pageflow/Cli/PageflowTypesCommand.php | 217 --- .../Pageflow/Http/CallablePageflowSharer.php | 29 - .../Pageflow/Http/CompositePageflowSharer.php | 41 - plugins/Pageflow/Http/PageflowAuth.php | 83 -- plugins/Pageflow/Http/PageflowChannel.php | 135 -- .../Http/PageflowEndpointsController.php | 56 - plugins/Pageflow/Http/PageflowPage.php | 81 -- plugins/Pageflow/Http/PageflowResponder.php | 321 ----- plugins/Pageflow/Http/PageflowShares.php | 53 - plugins/Pageflow/Http/PageflowStage.php | 210 --- plugins/Pageflow/Http/PageflowStream.php | 163 --- .../Pageflow/Http/RegistryPageflowSharer.php | 23 - plugins/Pageflow/Provider.php | 138 -- plugins/Pageflow/README.md | 130 -- plugins/Pageflow/Support/helpers.php | 107 -- plugins/Pageflow/module.json | 40 - plugins/Pageflow/resources/layouts/app.php | 153 -- plugins/Pageflow/ui/PAGEFLOW_GUIDE.pdf | Bin 492643 -> 0 bytes plugins/Pageflow/ui/PAGEFLOW_TUTORIAL.pdf | Bin 642570 -> 0 bytes plugins/Pageflow/ui/PAGEFLOW_UI_REFERENCE.pdf | Bin 359862 -> 0 bytes .../Pageflow/ui/PAGEFLOW_URL_PRESERVATION.pdf | Bin 122167 -> 0 bytes plugins/Pageflow/ui/PAGEFLOW_USAGE.pdf | Bin 246914 -> 0 bytes plugins/Pageflow/ui/core/csrf.ts | 103 -- plugins/Pageflow/ui/core/csrfRetry.ts | 123 -- plugins/Pageflow/ui/core/encryption.ts | 161 --- .../Pageflow/ui/core/eventHandler/events.ts | 52 - .../Pageflow/ui/core/eventHandler/index.ts | 109 -- plugins/Pageflow/ui/core/files.ts | 11 - plugins/Pageflow/ui/core/formData.ts | 45 - plugins/Pageflow/ui/core/head/index.ts | 154 -- plugins/Pageflow/ui/core/history.ts | 244 ---- plugins/Pageflow/ui/core/http/request.ts | 160 --- .../Pageflow/ui/core/http/requestParams.ts | 175 --- .../Pageflow/ui/core/http/requestStream.ts | 48 - plugins/Pageflow/ui/core/http/response.ts | 335 ----- plugins/Pageflow/ui/core/index.ts | 27 - plugins/Pageflow/ui/core/initialVisit.ts | 103 -- plugins/Pageflow/ui/core/modal.ts | 59 - plugins/Pageflow/ui/core/navigationType.ts | 37 - plugins/Pageflow/ui/core/objectUtils.ts | 38 - plugins/Pageflow/ui/core/page.ts | 174 --- plugins/Pageflow/ui/core/poll/index.ts | 53 - plugins/Pageflow/ui/core/poll/polls.ts | 50 - plugins/Pageflow/ui/core/precognition.ts | 88 -- plugins/Pageflow/ui/core/prefetched.ts | 241 ---- .../Pageflow/ui/core/progress-component.ts | 367 ----- plugins/Pageflow/ui/core/progress.ts | 65 - plugins/Pageflow/ui/core/queue.ts | 27 - plugins/Pageflow/ui/core/reactive.ts | 111 -- plugins/Pageflow/ui/core/router.ts | 428 ------ plugins/Pageflow/ui/core/scroll.ts | 88 -- plugins/Pageflow/ui/core/server.ts | 62 - plugins/Pageflow/ui/core/serviceWorker.ts | 60 - plugins/Pageflow/ui/core/sessionStorage.ts | 55 - plugins/Pageflow/ui/core/shouldIntercept.ts | 22 - plugins/Pageflow/ui/core/time.ts | 21 - plugins/Pageflow/ui/core/types.ts | 357 ----- plugins/Pageflow/ui/core/url.ts | 85 -- plugins/Pageflow/ui/index.ts | 38 - plugins/Pageflow/ui/pageflow-sw.js | 123 -- plugins/Pageflow/ui/react/App.tsx | 122 -- plugins/Pageflow/ui/react/Can.tsx | 47 - plugins/Pageflow/ui/react/Deferred.tsx | 66 - plugins/Pageflow/ui/react/Form.tsx | 325 ----- plugins/Pageflow/ui/react/Head.tsx | 108 -- plugins/Pageflow/ui/react/HeadContext.tsx | 6 - plugins/Pageflow/ui/react/Link.tsx | 277 ---- plugins/Pageflow/ui/react/PageContext.tsx | 6 - plugins/Pageflow/ui/react/WhenVisible.tsx | 107 -- .../Pageflow/ui/react/createPageflowApp.tsx | 122 -- plugins/Pageflow/ui/react/index.tsx | 37 - plugins/Pageflow/ui/react/server.ts | 1 - plugins/Pageflow/ui/react/useAuth.tsx | 66 - plugins/Pageflow/ui/react/useDirtyGuard.tsx | 57 - .../ui/react/useFlushOnIdentityChange.tsx | 31 - plugins/Pageflow/ui/react/useForm.tsx | 425 ------ plugins/Pageflow/ui/react/usePage.tsx | 19 - plugins/Pageflow/ui/react/usePoll.tsx | 31 - plugins/Pageflow/ui/react/usePrecognition.tsx | 105 -- plugins/Pageflow/ui/react/usePrefetch.tsx | 44 - .../Pageflow/ui/react/useReactiveProps.tsx | 60 - plugins/Pageflow/ui/react/useRemember.tsx | 19 - plugins/Pageflow/ui/tests/csrf.test.ts | 48 - .../Pageflow/ui/tests/precognition.test.ts | 30 - plugins/Pageflow/ui/tests/queue.test.ts | 40 - plugins/Pageflow/ui/tests/reactive.test.ts | 31 - .../ui/tests/resolvePageComponent.test.ts | 30 - plugins/Pageflow/ui/tests/sameOrigin.test.ts | 35 - plugins/Pageflow/ui/tests/url.test.ts | 65 - plugins/Pageflow/ui/ui.json | 14 - plugins/Pageflow/ui/vitest.config.ts | 18 - .../Infrastructure/RedisCacheAdapter.php | 130 -- .../Infrastructure/RedisConnection.php | 72 - .../RedisCache/Infrastructure/RedisLock.php | 84 -- .../Infrastructure/RedisQueueAdapter.php | 188 --- plugins/RedisCache/Provider.php | 85 -- plugins/RedisCache/module.json | 26 - .../Http/Stages/ApiRateLimitStage.php | 118 -- .../Http/Stages/HmacSignedStage.php | 77 - .../Http/Stages/RequireAuthStage.php | 124 -- .../Http/Stages/SecurityHeadersStage.php | 142 -- .../Http/Stages/ShieldStage.php | 85 -- plugins/SecurityFilters/Provider.php | 72 - plugins/SecurityFilters/module.json | 34 - .../Handlers/ArraySessionHandler.php | 63 - .../Contracts/CookieBackedHandler.php | 35 - .../Handlers/CookieSessionConfig.php | 89 -- .../Handlers/CookieSessionHandler.php | 306 ---- .../Handlers/FileSessionHandler.php | 90 -- .../Infrastructure/Http/StartSessionStage.php | 224 --- plugins/Session/Infrastructure/Store.php | 261 ---- plugins/Session/Provider.php | 147 -- plugins/Session/module.json | 35 - .../API/Contracts/SettingsServiceContract.php | 47 - .../Settings/API/DTOs/CompanySettingsDTO.php | 188 --- .../Settings/API/DTOs/ContactSettingsDTO.php | 83 -- .../API/DTOs/EmailProviderSettingsDTO.php | 109 -- .../Settings/API/DTOs/EmailSettingsDTO.php | 201 --- .../Settings/API/DTOs/SystemSettingsDTO.php | 173 --- .../Application/Services/SettingsService.php | 166 --- .../Domain/Entities/TenantSettings.php | 64 - .../Domain/ValueObjects/SettingsSection.php | 25 - .../Http/SettingsController.php | 194 --- .../Persistence/SettingsRepository.php | 67 - plugins/Settings/Provider.php | 60 - plugins/Settings/README.md | 83 -- ...1_create_tenant_settings_company_table.php | 58 - ...2_create_tenant_settings_contact_table.php | 46 - ...003_create_tenant_settings_email_table.php | 67 - ..._tenant_settings_email_providers_table.php | 50 - ...05_create_tenant_settings_system_table.php | 63 - plugins/Settings/module.json | 29 - .../API/Contracts/SeoServiceContract.php | 103 -- .../UrlPublishedIntegrationEvent.php | 42 - .../SiteSEO/Application/Jobs/IndexNowJob.php | 76 - .../Listeners/EnqueueIndexNowListener.php | 58 - .../Application/Services/SeoService.php | 182 --- plugins/SiteSEO/BaseObject.php | 37 - plugins/SiteSEO/Channel.php | 149 -- plugins/SiteSEO/Exceptions/SeoException.php | 12 - .../SiteSEO/Exceptions/SitemapException.php | 11 - plugins/SiteSEO/Helpers/Escape.php | 46 - plugins/SiteSEO/Indexing.php | 87 -- .../Gateways/SearchEngineGateway.php | 127 -- .../Infrastructure/Http/SeoController.php | 62 - .../SiteSEO/Interfaces/SchemaInterface.php | 13 - plugins/SiteSEO/Interfaces/SeoInterface.php | 10 - .../Interfaces/SitemapBuilderInterface.php | 66 - .../Interfaces/SitemapIndexInterface.php | 25 - .../SiteSEO/Interfaces/SitemapInterface.php | 10 - plugins/SiteSEO/OpenGraph.php | 92 -- plugins/SiteSEO/Ping.php | 68 - plugins/SiteSEO/Property.php | 34 - plugins/SiteSEO/Provider.php | 82 -- plugins/SiteSEO/RobotsTxtEditor.php | 379 ----- plugins/SiteSEO/RobotsTxtValidator.php | 224 --- plugins/SiteSEO/Schema.php | 87 -- plugins/SiteSEO/Schema/Thing.php | 63 - .../SiteSEO/Schema/Things/ContactPoint.php | 25 - plugins/SiteSEO/Schema/Things/Offer.php | 37 - .../SiteSEO/Schema/Things/Organization.php | 31 - plugins/SiteSEO/Schema/Things/Product.php | 43 - plugins/SiteSEO/Schema/Things/WebPage.php | 31 - plugins/SiteSEO/Sitemap.php | 420 ------ plugins/SiteSEO/Sitemap/LinksBuilder.php | 10 - plugins/SiteSEO/Sitemap/NewsBuilder.php | 87 -- plugins/SiteSEO/Sitemap/SitemapBuilder.php | 389 ----- plugins/SiteSEO/Sitemap/SitemapIndex.php | 53 - plugins/SiteSEO/Sitemap/SitemapParser.php | 79 - .../SiteSEO/StructuredProperties/Audio.php | 22 - .../SiteSEO/StructuredProperties/Image.php | 43 - .../StructuredProperty.php | 23 - .../SiteSEO/StructuredProperties/Video.php | 43 - .../SiteSEO/Support/ConditionalCallProxy.php | 27 - .../SiteSEO/Support/HasConditionalCalls.php | 36 - plugins/SiteSEO/Twitter.php | 25 - plugins/SiteSEO/TwitterProperty.php | 16 - plugins/SiteSEO/TwitterType.php | 83 -- plugins/SiteSEO/Type.php | 184 --- plugins/SiteSEO/Types/Article.php | 56 - plugins/SiteSEO/Types/Book.php | 42 - plugins/SiteSEO/Types/Music/Album.php | 37 - plugins/SiteSEO/Types/Music/Playlist.php | 29 - plugins/SiteSEO/Types/Music/RadioStation.php | 20 - plugins/SiteSEO/Types/Music/Song.php | 36 - plugins/SiteSEO/Types/Profile.php | 41 - plugins/SiteSEO/Types/Twitter/App.php | 54 - plugins/SiteSEO/Types/Twitter/Player.php | 20 - plugins/SiteSEO/Types/Twitter/Summary.php | 11 - .../Types/Twitter/SummaryLargeImage.php | 18 - plugins/SiteSEO/Types/Video/Episode.php | 64 - plugins/SiteSEO/Types/Video/Movie.php | 57 - plugins/SiteSEO/Types/Video/Other.php | 57 - plugins/SiteSEO/Types/Video/TvShow.php | 50 - plugins/SiteSEO/Types/Website.php | 11 - plugins/SiteSEO/XRobotsTag.php | 107 -- plugins/SiteSEO/module.json | 24 - .../Contracts/SocialAuthServiceContract.php | 29 - .../Services/SocialAuthService.php | 65 - .../Services/SocialLoginService.php | 144 -- .../Gateways/ProviderTokenGateway.php | 198 --- .../Http/Controllers/SocialAuthController.php | 162 --- .../Persistence/SocialIdentityRepository.php | 102 -- plugins/SocialAuth/Provider.php | 133 -- plugins/SocialAuth/Socialite/AbstractUser.php | 210 --- .../Socialite/Http/RedirectResponse.php | 33 - plugins/SocialAuth/Socialite/Http/Request.php | 45 - plugins/SocialAuth/Socialite/Http/Session.php | 45 - .../Socialite/One/AbstractProvider.php | 185 --- .../MissingTemporaryCredentialsException.php | 10 - .../One/MissingVerifierException.php | 10 - .../Socialite/One/TwitterProvider.php | 46 - plugins/SocialAuth/Socialite/One/User.php | 37 - .../SocialAuth/Socialite/Ports/Factory.php | 14 - .../SocialAuth/Socialite/Ports/Provider.php | 24 - plugins/SocialAuth/Socialite/Ports/User.php | 41 - .../SocialAuth/Socialite/SocialiteManager.php | 298 ---- .../SocialAuth/Socialite/Support/Config.php | 32 - .../SocialAuth/Socialite/Support/Manager.php | 55 - .../SocialAuth/Socialite/Support/helpers.php | 42 - .../Socialite/Two/AbstractProvider.php | 588 -------- .../Socialite/Two/BitbucketProvider.php | 113 -- .../Socialite/Two/FacebookProvider.php | 282 ---- .../Socialite/Two/GithubProvider.php | 108 -- .../Socialite/Two/GitlabProvider.php | 86 -- .../Socialite/Two/GoogleProvider.php | 95 -- .../Socialite/Two/InvalidStateException.php | 10 - .../Socialite/Two/LinkedInOpenIdProvider.php | 84 -- .../Socialite/Two/LinkedInProvider.php | 133 -- .../Socialite/Two/ProviderInterface.php | 20 - .../Socialite/Two/SlackOpenIdProvider.php | 65 - .../Socialite/Two/SlackProvider.php | 110 -- plugins/SocialAuth/Socialite/Two/Token.php | 50 - .../Socialite/Two/TwitterProvider.php | 124 -- plugins/SocialAuth/Socialite/Two/User.php | 88 -- .../SocialAuth/Socialite/Two/XProvider.php | 37 - .../SocialAuth/database/migrations/.gitkeep | 0 ..._000001_create_social_identities_table.php | 50 - plugins/SocialAuth/module.json | 32 - .../Infrastructure/LocalStorageAdapter.php | 226 --- .../Infrastructure/S3StorageAdapter.php | 152 -- plugins/Storage/Provider.php | 86 -- plugins/Storage/Support/helpers.php | 66 - plugins/Storage/config/storage.php | 77 - plugins/Storage/module.json | 26 - .../Contracts/InvitationServiceContract.php | 43 - .../Contracts/MembershipServiceContract.php | 50 - .../Contracts/TenantAdminServiceContract.php | 48 - .../TenantConnectionResolverContract.php | 34 - .../Contracts/TenantHostRegistryContract.php | 26 - .../Contracts/TenantHostServiceContract.php | 68 - .../API/Contracts/TenantRegistryContract.php | 34 - .../API/DTOs/HostVerificationInstructions.php | 45 - .../API/DTOs/HostVerificationResult.php | 51 - plugins/Tenancy/API/DTOs/InvitationResult.php | 35 - plugins/Tenancy/API/DTOs/TenantDetail.php | 62 - plugins/Tenancy/API/DTOs/TenantSelection.php | 34 - plugins/Tenancy/API/DTOs/TenantSummary.php | 48 - .../HostUnverifiedIntegrationEvent.php | 53 - .../HostVerifiedIntegrationEvent.php | 50 - ...AssignTenantMembershipOnUserRegistered.php | 43 - .../Tenancy/Application/Ports/AuditReader.php | 44 - .../Tenancy/Application/Ports/AuditSink.php | 23 - .../Tenancy/Application/Ports/AuditWriter.php | 27 - .../Tenancy/Application/Ports/DnsResolver.php | 31 - .../Application/Ports/InvitationStore.php | 34 - .../Application/Ports/MembershipReader.php | 25 - .../Application/Ports/MembershipWriter.php | 19 - .../Application/Ports/TenantHostStore.php | 36 - .../Application/Ports/TenantProvisioner.php | 38 - .../Application/Ports/TenantWriteStore.php | 43 - .../Application/Services/AuditService.php | 49 - .../Services/InvitationService.php | 97 -- .../Services/MembershipService.php | 73 - .../Services/TenantAdminService.php | 260 ---- .../Services/TenantHostService.php | 212 --- .../Tenancy/Domain/Entities/AuditEntry.php | 57 - .../Tenancy/Domain/Entities/Invitation.php | 52 - .../Tenancy/Domain/Entities/Membership.php | 80 -- plugins/Tenancy/Domain/Entities/Tenant.php | 98 -- .../Tenancy/Domain/Entities/TenantHost.php | 78 - .../Exceptions/HostConflictException.php | 19 - .../Exceptions/HostNotFoundException.php | 18 - .../Exceptions/HostQuotaExceededException.php | 18 - .../Exceptions/InvalidHostnameException.php | 17 - .../Exceptions/InvalidInvitationException.php | 22 - .../Domain/Exceptions/NotAMemberException.php | 19 - .../Exceptions/TenantUnavailableException.php | 50 - .../Exceptions/UnknownTenantException.php | 14 - .../Domain/ValueObjects/HostStatus.php | 34 - .../Tenancy/Domain/ValueObjects/Hostname.php | 90 -- .../Domain/ValueObjects/InvitationStatus.php | 23 - .../Domain/ValueObjects/MembershipStatus.php | 22 - .../Domain/ValueObjects/TenantStatus.php | 24 - .../Cli/AddTenantHostCommand.php | 235 --- .../Cli/Concerns/ManagesTenantDatabase.php | 77 - .../Cli/CreateTenantCommand.php | 421 ------ .../Cli/DeleteTenantCommand.php | 159 -- .../Cli/MigrateTenantsCommand.php | 221 --- .../Cli/RememberTenantCommand.php | 101 -- .../Infrastructure/Dns/SystemDnsResolver.php | 80 -- .../Http/Controllers/InvitationController.php | 53 - .../Controllers/TenantAdminController.php | 118 -- .../Http/Controllers/TenantController.php | 93 -- .../Http/Controllers/TenantHostController.php | 194 --- .../Http/Controllers/TenantPageController.php | 82 -- .../Identification/ClaimTenantIdentifier.php | 23 - .../Identification/DomainTenantIdentifier.php | 109 -- .../Identification/HostTenantIdentifier.php | 48 - .../Http/Identification/TenantIdentifier.php | 34 - .../Http/Stages/RequireTenantStage.php | 44 - .../Http/Stages/TenantContextStage.php | 283 ---- .../Persistence/AuditLogRepository.php | 123 -- .../Infrastructure/Persistence/AuditTrail.php | 60 - .../Persistence/InvitationRepository.php | 110 -- .../Persistence/MembershipRepository.php | 93 -- .../Persistence/TenantAdminRepository.php | 130 -- .../Persistence/TenantHostRegistry.php | 86 -- .../Persistence/TenantHostRepository.php | 187 --- .../Persistence/TenantRegistry.php | 92 -- .../Provisioning/DdlTenantProvisioner.php | 144 -- .../TenantConnectionResolver.php | 208 --- plugins/Tenancy/Provider.php | 433 ------ plugins/Tenancy/README.md | 238 --- plugins/Tenancy/Support/TenantsFile.php | 120 -- plugins/Tenancy/Support/Token.php | 45 - ...2026_06_22_000001_create_tenants_table.php | 65 - ...06_22_000002_create_user_tenants_table.php | 52 - ...000003_create_tenant_invitations_table.php | 53 - ...06_22_000006_create_tenant_hosts_table.php | 59 - .../Tenancy/database/tenant-template/.gitkeep | 0 plugins/Tenancy/module.json | 273 ---- .../Tenancy/resources/views/hosts/index.php | 178 --- .../Tenancy/resources/views/layouts/app.php | 147 -- .../resources/views/tenants/create.php | 110 -- .../Tenancy/resources/views/tenants/edit.php | 97 -- .../Tenancy/resources/views/tenants/index.php | 89 -- .../resources/views/tenants/manage.php | 96 -- plugins/Tenancy/ui/README.md | 71 - .../Tenancy/ui/admin/Pages/Tenant/Create.tsx | 187 --- .../Tenancy/ui/admin/Pages/Tenant/Edit.tsx | 146 -- .../Tenancy/ui/admin/Pages/Tenant/Manage.tsx | 119 -- plugins/Tenancy/ui/components/StatusBadge.tsx | 27 - plugins/Tenancy/ui/components/TenantBadge.tsx | 21 - plugins/Tenancy/ui/index.ts | 14 - plugins/Tenancy/ui/lib/client.ts | 146 -- .../Tenancy/ui/site/Pages/Tenant/Hosts.tsx | 220 --- .../Tenancy/ui/site/Pages/Tenant/Index.tsx | 90 -- plugins/Tenancy/ui/ui.json | 10 - plugins/User/API/Contracts/.gitkeep | 0 .../Contracts/TenantProfileReaderContract.php | 32 - .../API/Contracts/UserServiceContract.php | 108 -- plugins/User/API/DTOs/FeedbackPage.php | 40 - plugins/User/API/DTOs/ListFeedbackQuery.php | 46 - plugins/User/API/DTOs/ListUsersQuery.php | 41 - plugins/User/API/DTOs/RegisterUserDTO.php | 153 -- plugins/User/API/DTOs/SubmitFeedbackDTO.php | 60 - .../DTOs/UpdateNotificationPreferencesDTO.php | 70 - .../User/API/DTOs/UpdatePreferencesDTO.php | 68 - plugins/User/API/DTOs/UpdatePrivacyDTO.php | 47 - plugins/User/API/DTOs/UpdateProfileDTO.php | 70 - plugins/User/API/DTOs/UpdateUserDTO.php | 80 -- plugins/User/API/DTOs/UserDTO.php | 71 - plugins/User/API/DTOs/UserPage.php | 38 - plugins/User/API/DTOs/VerifyEmailDTO.php | 36 - plugins/User/API/DTOs/VerifyEmailResult.php | 29 - .../FeedbackSubmittedIntegrationEvent.php | 50 - .../GenericIntegrationEvent.php | 30 - .../UserDeletedIntegrationEvent.php | 42 - .../UserRegisteredIntegrationEvent.php | 60 - .../UserUpdatedIntegrationEvent.php | 46 - .../User/Application/Ports/BreachChecker.php | 19 - .../User/Application/Ports/FeedbackStore.php | 30 - plugins/User/Application/Ports/OutboxPort.php | 25 - plugins/User/Application/Ports/UserStore.php | 51 - plugins/User/Application/Services/.gitkeep | 0 .../Application/Services/FeedbackService.php | 181 --- .../Services/OutboxRelayService.php | 55 - .../Services/TenantProfileProvisioner.php | 130 -- .../User/Application/Services/UserService.php | 769 ---------- .../Services/UserSettingsService.php | 176 --- plugins/User/Domain/.gitkeep | 0 .../User/Domain/Entities/FeedbackEntry.php | 102 -- plugins/User/Domain/Entities/User.php | 294 ---- .../Entities/UserNotificationPreferences.php | 105 -- .../User/Domain/Entities/UserPreferences.php | 120 -- .../Domain/Entities/UserPrivacySettings.php | 94 -- plugins/User/Domain/Entities/UserProfile.php | 144 -- .../Domain/Events/UserDeletedDomainEvent.php | 19 - .../Events/UserRegisteredDomainEvent.php | 25 - .../Domain/Events/UserUpdatedDomainEvent.php | 23 - .../Exceptions/DuplicateUserException.php | 25 - plugins/User/Domain/ValueObjects/Email.php | 32 - .../Domain/ValueObjects/FeedbackCategory.php | 38 - .../User/Domain/ValueObjects/FeedbackId.php | 43 - .../Domain/ValueObjects/FeedbackMessage.php | 43 - .../Domain/ValueObjects/FeedbackRating.php | 59 - .../Domain/ValueObjects/FeedbackStatus.php | 39 - .../Domain/ValueObjects/PasswordPolicy.php | 67 - .../Domain/ValueObjects/ProfileVisibility.php | 22 - plugins/User/Domain/ValueObjects/Theme.php | 21 - plugins/User/Domain/ValueObjects/Ulid.php | 57 - plugins/User/Domain/ValueObjects/UserId.php | 43 - plugins/User/Domain/ValueObjects/Username.php | 35 - .../User/Infrastructure/Audit/AuditLogger.php | 106 -- .../Cli/RelayUserOutboxCommand.php | 41 - .../Gateways/NullBreachChecker.php | 22 - .../Gateways/PwnedPasswordGateway.php | 68 - plugins/User/Infrastructure/Http/.gitkeep | 0 .../Http/Controllers/FeedbackController.php | 57 - .../Http/Controllers/UserController.php | 257 ---- .../Http/Controllers/UserFlowController.php | 119 -- .../Http/Controllers/UserPageController.php | 81 -- .../Controllers/UserSettingsController.php | 69 - .../ProvisionTenantProfileListener.php | 67 - .../Infrastructure/Outbox/OutboxRelay.php | 90 -- .../Infrastructure/Outbox/OutboxWriter.php | 93 -- .../Persistence/FeedbackRepository.php | 145 -- .../Persistence/OutboxRepository.php | 121 -- .../Persistence/UserRepository.php | 385 ----- .../Persistence/UserSettingsRepository.php | 174 --- plugins/User/Provider.php | 228 --- plugins/User/README.md | 552 ------- plugins/User/User-Plugin.pdf | Bin 386726 -> 0 bytes plugins/User/config/user.php | 17 - .../User/database/factories/UserFactory.php | 33 - .../2026_01_01_000000_create_user_table.php | 138 -- ..._01_01_000001_create_user_outbox_table.php | 120 -- ..._add_email_verification_token_to_users.php | 46 - ..._19_000001_add_platform_admin_to_users.php | 66 - plugins/User/database/seeders/UserSeeder.php | 65 - ...6_29_000001_create_user_profiles_table.php | 56 - ...002_create_user_privacy_settings_table.php | 50 - ...9_000003_create_user_preferences_table.php | 54 - ...te_user_notification_preferences_table.php | 68 - ...6_29_000005_create_user_feedback_table.php | 55 - plugins/User/module.json | 286 ---- .../User/resources/views/account/feedback.php | 116 -- .../User/resources/views/account/settings.php | 148 -- .../User/resources/views/account/verify.php | 78 - .../User/resources/views/emails/verify.php | 49 - plugins/User/resources/views/layouts/app.php | 138 -- plugins/User/resources/views/user.php | 22 - plugins/User/resources/views/users/create.php | 66 - plugins/User/resources/views/users/edit.php | 92 -- plugins/User/resources/views/users/index.php | 90 -- plugins/User/resources/views/users/show.php | 67 - plugins/User/ui/README.md | 59 - plugins/User/ui/admin/Pages/User/Index.tsx | 76 - plugins/User/ui/admin/Pages/User/Show.tsx | 58 - plugins/User/ui/components/UserBadge.tsx | 35 - plugins/User/ui/index.ts | 6 - plugins/User/ui/site/Pages/User/Profile.tsx | 54 - plugins/User/ui/site/Pages/User/Register.tsx | 98 -- .../User/ui/site/Pages/User/VerifyEmail.tsx | 76 - plugins/User/ui/ui.json | 10 - plugins/Validation/AbstractDto.php | 93 -- plugins/Validation/Provider.php | 80 -- plugins/Validation/README.md | 159 -- plugins/Validation/Rules/CommonRules.php | 473 ------ plugins/Validation/Rules/FinancialRules.php | 100 -- plugins/Validation/Validator.php | 418 ------ plugins/Validation/config/validation.php | 39 - plugins/Validation/module.json | 17 - .../API/Contracts/ViewDecoratorContract.php | 15 - .../API/Contracts/ViewRendererContract.php | 55 - plugins/View/Exceptions/ViewException.php | 30 - .../View/Infrastructure/PhpViewRenderer.php | 371 ----- .../View/Infrastructure/SidebarManager.php | 296 ---- plugins/View/Provider.php | 136 -- plugins/View/module.json | 19 - .../API/Contracts/ViteContract.php | 44 - .../ViteManifestNotFoundException.php | 16 - .../Infrastructure/ManifestReader.php | 66 - plugins/ViteManifest/Infrastructure/Vite.php | 431 ------ plugins/ViteManifest/Provider.php | 64 - plugins/ViteManifest/README.md | 83 -- plugins/ViteManifest/Support/Html.php | 31 - plugins/ViteManifest/Support/helpers.php | 77 - plugins/ViteManifest/ViteConfig.php | 87 -- plugins/ViteManifest/ViteFactory.php | 55 - plugins/ViteManifest/module.json | 24 - tests/Unit/Plugins/I18n/TranslatorTest.php | 9 +- .../Unit/Plugins/Validation/ValidatorTest.php | 5 +- 864 files changed, 1136 insertions(+), 87378 deletions(-) delete mode 100644 plugins/Audit/API/Contracts/AuditReaderContract.php delete mode 100644 plugins/Audit/API/Contracts/AuditServiceContract.php delete mode 100644 plugins/Audit/Application/Ports/AuditWriter.php delete mode 100644 plugins/Audit/Application/Services/AuditService.php delete mode 100644 plugins/Audit/Domain/Entities/AuditEntry.php delete mode 100644 plugins/Audit/Infrastructure/Persistence/AuditLogRepository.php delete mode 100644 plugins/Audit/Infrastructure/Persistence/AuditTrail.php delete mode 100644 plugins/Audit/Provider.php delete mode 100644 plugins/Audit/database/migrations/.gitkeep delete mode 100644 plugins/Audit/database/tenant-template/2026_06_22_000005_create_audit_log_table.php delete mode 100644 plugins/Audit/module.json delete mode 100644 plugins/Auth/API/Contracts/AuthServiceContract.php delete mode 100644 plugins/Auth/API/Contracts/RefreshTokenServiceContract.php delete mode 100644 plugins/Auth/API/DTOs/PersonalAccessTokenResult.php delete mode 100644 plugins/Auth/API/DTOs/RefreshRotation.php delete mode 100644 plugins/Auth/API/DTOs/RefreshTokenIssued.php delete mode 100644 plugins/Auth/API/DTOs/TokenDTO.php delete mode 100644 plugins/Auth/API/Guard.php delete mode 100644 plugins/Auth/API/ScopeInheritance.php delete mode 100644 plugins/Auth/AUTH_GUIDE.pdf delete mode 100644 plugins/Auth/Application/Auth/AuthManager.php delete mode 100644 plugins/Auth/Application/Auth/AuthUserProxy.php delete mode 100644 plugins/Auth/Application/Auth/GuardAccessor.php delete mode 100644 plugins/Auth/Application/Auth/GuardBehaviour.php delete mode 100644 plugins/Auth/Application/Auth/ModelUserProvider.php delete mode 100644 plugins/Auth/Application/Auth/PasswordResetBroker.php delete mode 100644 plugins/Auth/Application/Auth/PersonalAccessTokenFactory.php delete mode 100644 plugins/Auth/Application/Auth/RoleResolver.php delete mode 100644 plugins/Auth/Application/Auth/StatefulSessionGuard.php delete mode 100644 plugins/Auth/Application/Ports/Authenticatable.php delete mode 100644 plugins/Auth/Application/Ports/Driver.php delete mode 100644 plugins/Auth/Application/Ports/GuardContext.php delete mode 100644 plugins/Auth/Application/Ports/GuardDriver.php delete mode 100644 plugins/Auth/Application/Ports/PasswordBroker.php delete mode 100644 plugins/Auth/Application/Ports/RefreshTokenStore.php delete mode 100644 plugins/Auth/Application/Ports/StatefulGuard.php delete mode 100644 plugins/Auth/Application/Ports/SupportsBasicAuth.php delete mode 100644 plugins/Auth/Application/Ports/UserProvider.php delete mode 100644 plugins/Auth/Application/Services/AuthService.php delete mode 100644 plugins/Auth/Application/Services/DeviceSessionService.php delete mode 100644 plugins/Auth/Application/Services/MobileAuthService.php delete mode 100644 plugins/Auth/Application/Services/RefreshTokenService.php delete mode 100644 plugins/Auth/Domain/Entities/PersonalAccessToken.php delete mode 100644 plugins/Auth/Domain/Entities/RefreshTokenRecord.php delete mode 100644 plugins/Auth/Domain/Exceptions/AuthenticationException.php delete mode 100644 plugins/Auth/Domain/Exceptions/AuthorizationException.php delete mode 100644 plugins/Auth/Domain/Exceptions/InvalidAuthTokenException.php delete mode 100644 plugins/Auth/Domain/Exceptions/InvalidRefreshTokenException.php delete mode 100644 plugins/Auth/Domain/Exceptions/MissingScopeException.php delete mode 100644 plugins/Auth/Domain/ValueObjects/Recaller.php delete mode 100644 plugins/Auth/Infrastructure/Auth/Drivers/JwtDriver.php delete mode 100644 plugins/Auth/Infrastructure/Auth/Drivers/RequestDriver.php delete mode 100644 plugins/Auth/Infrastructure/Auth/Drivers/ResolvesFromVerdict.php delete mode 100644 plugins/Auth/Infrastructure/Auth/Drivers/SessionDriver.php delete mode 100644 plugins/Auth/Infrastructure/Auth/Drivers/TokenDriver.php delete mode 100644 plugins/Auth/Infrastructure/Cli/PruneAccessTokensCommand.php delete mode 100644 plugins/Auth/Infrastructure/Http/Controllers/AuthTokenController.php delete mode 100644 plugins/Auth/Infrastructure/Http/Controllers/MobileAuthController.php delete mode 100644 plugins/Auth/Infrastructure/Http/Controllers/PasswordResetController.php delete mode 100644 plugins/Auth/Infrastructure/Http/Controllers/PersonalAccessTokenController.php delete mode 100644 plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php delete mode 100644 plugins/Auth/Infrastructure/Http/Controllers/TransientTokenController.php delete mode 100644 plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php delete mode 100644 plugins/Auth/Infrastructure/Persistence/DeviceSessionRepository.php delete mode 100644 plugins/Auth/Infrastructure/Persistence/PersonalAccessTokenRepository.php delete mode 100644 plugins/Auth/Infrastructure/Persistence/RefreshTokenRepository.php delete mode 100644 plugins/Auth/Provider.php delete mode 100644 plugins/Auth/README.md delete mode 100644 plugins/Auth/Security/JwtAuthLayer.php delete mode 100644 plugins/Auth/Security/PersonalAccessTokenLayer.php delete mode 100644 plugins/Auth/Support/Token.php delete mode 100644 plugins/Auth/Support/helpers.php delete mode 100644 plugins/Auth/config/auth.php delete mode 100644 plugins/Auth/database/migrations/.gitkeep delete mode 100644 plugins/Auth/database/tenant-template/2026_06_05_000001_create_personal_access_tokens_table.php delete mode 100644 plugins/Auth/database/tenant-template/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php delete mode 100644 plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php delete mode 100644 plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php delete mode 100644 plugins/Auth/module.json delete mode 100644 plugins/Auth/resources/views/password-changed.php delete mode 100644 plugins/Auth/resources/views/password-otp.php delete mode 100644 plugins/Authorization/API/Contracts/AuthorizationServiceContract.php delete mode 100644 plugins/Authorization/Application/Services/AuthorizationService.php delete mode 100644 plugins/Authorization/Engine/CachedEnforcer.php delete mode 100644 plugins/Authorization/Engine/Config.php delete mode 100644 plugins/Authorization/Engine/Constants.php delete mode 100644 plugins/Authorization/Engine/CoreEnforcer.php delete mode 100644 plugins/Authorization/Engine/Effector/DefaultEffector.php delete mode 100644 plugins/Authorization/Engine/Effector/Effector.php delete mode 100644 plugins/Authorization/Engine/EnforceContext.php delete mode 100644 plugins/Authorization/Engine/Enforcer.php delete mode 100644 plugins/Authorization/Engine/Exceptions/BatchOperationException.php delete mode 100644 plugins/Authorization/Engine/Exceptions/CannotSaveFilteredPolicy.php delete mode 100644 plugins/Authorization/Engine/Exceptions/CasbinException.php delete mode 100644 plugins/Authorization/Engine/Exceptions/EmptyConditionException.php delete mode 100644 plugins/Authorization/Engine/Exceptions/EvalFunctionException.php delete mode 100644 plugins/Authorization/Engine/Exceptions/InvalidFilePathException.php delete mode 100644 plugins/Authorization/Engine/Exceptions/InvalidFilterTypeException.php delete mode 100644 plugins/Authorization/Engine/Exceptions/NotImplementedException.php delete mode 100644 plugins/Authorization/Engine/Exceptions/ObjConditionException.php delete mode 100644 plugins/Authorization/Engine/Interfaces/CacheableParam.php delete mode 100644 plugins/Authorization/Engine/Interfaces/ConditionalRoleManager.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Config.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Logger.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Persist/Adapter.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Persist/BatchAdapter.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Persist/FilteredAdapter.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Persist/UpdatableAdapter.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Persist/Watcher.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Persist/WatcherEx.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Persist/WatcherUpdatable.php delete mode 100644 plugins/Authorization/Engine/Interfaces/RoleManager.php delete mode 100644 plugins/Authorization/Engine/Interfaces/Supports/AccessPermission.php delete mode 100644 plugins/Authorization/Engine/InternalEnforcer.php delete mode 100644 plugins/Authorization/Engine/Log/Log.php delete mode 100644 plugins/Authorization/Engine/Log/Logger/DefaultLogger.php delete mode 100644 plugins/Authorization/Engine/ManagementEnforcer.php delete mode 100644 plugins/Authorization/Engine/Model/Assertion.php delete mode 100644 plugins/Authorization/Engine/Model/FunctionMap.php delete mode 100644 plugins/Authorization/Engine/Model/Model.php delete mode 100644 plugins/Authorization/Engine/Model/Policy.php delete mode 100644 plugins/Authorization/Engine/Persist/AdapterHelper.php delete mode 100644 plugins/Authorization/Engine/Persist/Adapters/FileAdapter.php delete mode 100644 plugins/Authorization/Engine/Persist/Adapters/FileFilteredAdapter.php delete mode 100644 plugins/Authorization/Engine/Persist/Adapters/Filter.php delete mode 100644 plugins/Authorization/Engine/RBAC/ConditionalDomainManager.php delete mode 100644 plugins/Authorization/Engine/RBAC/ConditionalRoleManager.php delete mode 100644 plugins/Authorization/Engine/RBAC/DomainManager.php delete mode 100644 plugins/Authorization/Engine/RBAC/Role.php delete mode 100644 plugins/Authorization/Engine/RBAC/RoleManager.php delete mode 100644 plugins/Authorization/Engine/RBAC/Supports/BaseManager.php delete mode 100644 plugins/Authorization/Engine/RBAC/Supports/DomainManager.php delete mode 100644 plugins/Authorization/Engine/RBAC/Supports/RoleManager.php delete mode 100644 plugins/Authorization/Engine/Util/BuiltinOperations.php delete mode 100644 plugins/Authorization/Engine/functions.php delete mode 100644 plugins/Authorization/Infrastructure/Cli/SeedPolicyCommand.php delete mode 100644 plugins/Authorization/Infrastructure/Http/Stages/PolicyFilterStage.php delete mode 100644 plugins/Authorization/Infrastructure/Persistence/DatabasePolicyAdapter.php delete mode 100644 plugins/Authorization/Provider.php delete mode 100644 plugins/Authorization/config/policy.seed.csv delete mode 100644 plugins/Authorization/config/rbac_model.conf delete mode 100644 plugins/Authorization/database/migrations/.gitkeep delete mode 100644 plugins/Authorization/database/tenant-template/2026_06_05_000001_create_casbin_rule_table.php delete mode 100644 plugins/Authorization/module.json delete mode 100644 plugins/Commands/API/Contracts/MigrationServiceContract.php delete mode 100644 plugins/Commands/API/Contracts/ModuleManagementServiceContract.php delete mode 100644 plugins/Commands/API/DTOs/MigrateRequest.php delete mode 100644 plugins/Commands/API/DTOs/MigrateResponse.php delete mode 100644 plugins/Commands/API/DTOs/MigrateStatusRequest.php delete mode 100644 plugins/Commands/API/DTOs/MigrateStatusResponse.php delete mode 100644 plugins/Commands/API/DTOs/ModuleAddRequest.php delete mode 100644 plugins/Commands/API/DTOs/ModuleAddResponse.php delete mode 100644 plugins/Commands/API/DTOs/ModuleRemoveRequest.php delete mode 100644 plugins/Commands/API/DTOs/ModuleRemoveResponse.php delete mode 100644 plugins/Commands/Application/Services/CommandsInfrastructureService.php delete mode 100644 plugins/Commands/Application/Services/MigrationService.php delete mode 100644 plugins/Commands/Application/Services/ModuleManagementService.php delete mode 100644 plugins/Commands/Approval/MigrationApprovalManager.php delete mode 100644 plugins/Commands/Backup/BackupManager.php delete mode 100644 plugins/Commands/Configuration/ConfigurationValidator.php delete mode 100644 plugins/Commands/Configuration/EnvironmentConfigurationLoader.php delete mode 100644 plugins/Commands/Deployment/DeploymentLockManager.php delete mode 100644 plugins/Commands/Deployment/DeploymentLockedException.php delete mode 100644 plugins/Commands/Exceptions/ConfigurationException.php delete mode 100644 plugins/Commands/Exceptions/ServiceException.php delete mode 100644 plugins/Commands/Infrastructure/Gateways/LetMigrateGateway.php delete mode 100644 plugins/Commands/Infrastructure/Gateways/ShellGateway.php delete mode 100644 plugins/Commands/Infrastructure/Http/Commands/ModuleAddCommand.php delete mode 100644 plugins/Commands/Infrastructure/Http/Commands/ModuleRemoveCommand.php delete mode 100644 plugins/Commands/Infrastructure/Http/Commands/RouteListCommand.php delete mode 100644 plugins/Commands/Infrastructure/Persistence/ApprovalRepository.php delete mode 100644 plugins/Commands/Infrastructure/Persistence/BackupRepository.php delete mode 100644 plugins/Commands/Infrastructure/Persistence/CommandAuditLogRepository.php delete mode 100644 plugins/Commands/Infrastructure/Persistence/DeploymentLockRepository.php delete mode 100644 plugins/Commands/Infrastructure/Persistence/MigrationRepository.php delete mode 100644 plugins/Commands/Infrastructure/Persistence/ModuleRepository.php delete mode 100644 plugins/Commands/Logging/CommandExecutionLogger.php delete mode 100644 plugins/Commands/Provider.php delete mode 100644 plugins/Commands/Secrets/SecretsManager.php delete mode 100644 plugins/Commands/Validation/PreFlightValidator.php delete mode 100644 plugins/Commands/module.json delete mode 100644 plugins/Cookie/Infrastructure/CookieJar.php delete mode 100644 plugins/Cookie/Infrastructure/Http/QueuedCookiesStage.php delete mode 100644 plugins/Cookie/Provider.php delete mode 100644 plugins/Cookie/Support/helpers.php delete mode 100644 plugins/Cookie/config/cookie.php delete mode 100644 plugins/Cookie/module.json delete mode 100644 plugins/Crypto/Infrastructure/AesEncrypter.php delete mode 100644 plugins/Crypto/Infrastructure/PasswordHasher.php delete mode 100644 plugins/Crypto/Provider.php delete mode 100644 plugins/Crypto/module.json delete mode 100644 plugins/Database/API/Contracts/DatabaseConfigurationContract.php delete mode 100644 plugins/Database/API/Contracts/DatabaseConnectionManagerContract.php delete mode 100644 plugins/Database/Exceptions/ConnectionException.php delete mode 100644 plugins/Database/Infrastructure/Drivers/DatabaseConfigurationFactory.php delete mode 100644 plugins/Database/Infrastructure/Drivers/MySQLConfiguration.php delete mode 100644 plugins/Database/Infrastructure/Drivers/PostgreSQLConfiguration.php delete mode 100644 plugins/Database/Infrastructure/Drivers/SQLiteConfiguration.php delete mode 100644 plugins/Database/Infrastructure/Drivers/SqlServerConfiguration.php delete mode 100644 plugins/Database/Infrastructure/Persistence/ConnectionManager.php delete mode 100644 plugins/Database/Infrastructure/Persistence/MultiDriverDatabaseAdapter.php delete mode 100644 plugins/Database/Infrastructure/Persistence/PooledDatabaseAdapter.php delete mode 100644 plugins/Database/Infrastructure/Persistence/SavepointGrammar.php delete mode 100644 plugins/Database/Infrastructure/Pool/ConnectionPool.php delete mode 100644 plugins/Database/Infrastructure/Pool/PoolConfiguration.php delete mode 100644 plugins/Database/Infrastructure/Pool/PooledConnection.php delete mode 100644 plugins/Database/Provider.php delete mode 100644 plugins/Database/module.json delete mode 100644 plugins/DevTools/Commands/ConfigClearCommand.php delete mode 100644 plugins/DevTools/Commands/ConfigShowCommand.php delete mode 100644 plugins/DevTools/Commands/GeneratorCommand.php delete mode 100644 plugins/DevTools/Commands/MakePluginCommand.php delete mode 100644 plugins/DevTools/Commands/MakeServiceCommand.php delete mode 100644 plugins/DevTools/Commands/ModuleInfoCommand.php delete mode 100644 plugins/DevTools/Commands/ModuleListCommand.php delete mode 100644 plugins/DevTools/Commands/ProjectListCommand.php delete mode 100644 plugins/DevTools/Commands/RoutesListCommand.php delete mode 100644 plugins/DevTools/Provider.php delete mode 100644 plugins/DevTools/module.json delete mode 100644 plugins/Edge/API/Contracts/EdgeServiceContract.php delete mode 100644 plugins/Edge/Application/EdgeService.php delete mode 100644 plugins/Edge/Domain/CacheProfile.php delete mode 100644 plugins/Edge/Domain/EdgePlan.php delete mode 100644 plugins/Edge/Domain/ServeModel.php delete mode 100644 plugins/Edge/Domain/ServerStack.php delete mode 100644 plugins/Edge/Domain/Site.php delete mode 100644 plugins/Edge/Domain/Strategy.php delete mode 100644 plugins/Edge/Domain/SwooleOptions.php delete mode 100644 plugins/Edge/Domain/TlsConfig.php delete mode 100644 plugins/Edge/Domain/TlsMode.php delete mode 100644 plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php delete mode 100644 plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php delete mode 100644 plugins/Edge/Infrastructure/Cli/EdgeServiceCommand.php delete mode 100644 plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php delete mode 100644 plugins/Edge/Infrastructure/ConfigRenderer.php delete mode 100644 plugins/Edge/Infrastructure/HostsFileWriter.php delete mode 100644 plugins/Edge/Infrastructure/ServiceRenderer.php delete mode 100644 plugins/Edge/Infrastructure/SiteCollector.php delete mode 100644 plugins/Edge/Infrastructure/StreamConfigWriter.php delete mode 100644 plugins/Edge/Infrastructure/SystemProbe.php delete mode 100644 plugins/Edge/Provider.php delete mode 100644 plugins/Edge/README.md delete mode 100644 plugins/Edge/Support/helpers.php delete mode 100644 plugins/Edge/USAGE.md delete mode 100644 plugins/Edge/config/edge.php delete mode 100644 plugins/Edge/module.json delete mode 100644 plugins/Feedback/API/DTOs/FeedbackPage.php delete mode 100644 plugins/Feedback/API/DTOs/ListFeedbackQuery.php delete mode 100644 plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php delete mode 100644 plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php delete mode 100644 plugins/Feedback/Application/Ports/FeedbackStore.php delete mode 100644 plugins/Feedback/Application/Services/FeedbackService.php delete mode 100644 plugins/Feedback/Domain/Entities/FeedbackEntry.php delete mode 100644 plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php delete mode 100644 plugins/Feedback/Domain/ValueObjects/FeedbackId.php delete mode 100644 plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php delete mode 100644 plugins/Feedback/Domain/ValueObjects/FeedbackRating.php delete mode 100644 plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php delete mode 100644 plugins/Feedback/Domain/ValueObjects/Ulid.php delete mode 100644 plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php delete mode 100644 plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php delete mode 100644 plugins/Feedback/Provider.php delete mode 100644 plugins/Feedback/README.md delete mode 100644 plugins/Feedback/database/migrations/.gitkeep delete mode 100644 plugins/Feedback/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php delete mode 100644 plugins/Feedback/module.json delete mode 100644 plugins/HttpClient/Infrastructure/CurlHttpClient.php delete mode 100644 plugins/HttpClient/Infrastructure/PendingRequest.php delete mode 100644 plugins/HttpClient/Provider.php delete mode 100644 plugins/HttpClient/module.json delete mode 100644 plugins/I18n/Infrastructure/Http/LocaleStage.php delete mode 100644 plugins/I18n/Provider.php delete mode 100644 plugins/I18n/Support/Lang.php delete mode 100644 plugins/I18n/Support/helpers.php delete mode 100644 plugins/I18n/Translator.php delete mode 100644 plugins/I18n/lang/en/validation.php delete mode 100644 plugins/I18n/module.json delete mode 100644 plugins/Logger/Infrastructure/AbstractLogger.php delete mode 100644 plugins/Logger/Infrastructure/FileLogger.php delete mode 100644 plugins/Logger/Infrastructure/NullLogger.php delete mode 100644 plugins/Logger/Infrastructure/PsrLoggerBridge.php delete mode 100644 plugins/Logger/Infrastructure/StreamLogger.php delete mode 100644 plugins/Logger/Provider.php delete mode 100644 plugins/Logger/config/logger.php delete mode 100644 plugins/Logger/module.json delete mode 100644 plugins/Mail/API/Contracts/MailerContract.php delete mode 100644 plugins/Mail/Application/Jobs/SendMailJob.php delete mode 100644 plugins/Mail/Application/Mailer.php delete mode 100644 plugins/Mail/Domain/Address.php delete mode 100644 plugins/Mail/Domain/Attachment.php delete mode 100644 plugins/Mail/Domain/MailException.php delete mode 100644 plugins/Mail/Domain/Message.php delete mode 100644 plugins/Mail/Domain/Priority.php delete mode 100644 plugins/Mail/Infrastructure/Http/MailDemoController.php delete mode 100644 plugins/Mail/Infrastructure/Mime/MimeBuilder.php delete mode 100644 plugins/Mail/Infrastructure/Security/DkimSigner.php delete mode 100644 plugins/Mail/Infrastructure/Transport/ArrayTransport.php delete mode 100644 plugins/Mail/Infrastructure/Transport/LogTransport.php delete mode 100644 plugins/Mail/Infrastructure/Transport/MailTransport.php delete mode 100644 plugins/Mail/Infrastructure/Transport/SendmailTransport.php delete mode 100644 plugins/Mail/Infrastructure/Transport/SmtpTransport.php delete mode 100644 plugins/Mail/Infrastructure/Transport/Transport.php delete mode 100644 plugins/Mail/Provider.php delete mode 100644 plugins/Mail/README.md delete mode 100644 plugins/Mail/config/mail.php delete mode 100644 plugins/Mail/module.json delete mode 100644 plugins/OAuth2/Application/Ports/AuthCodeStore.php delete mode 100644 plugins/OAuth2/Application/Ports/AuthorizationFlow.php delete mode 100644 plugins/OAuth2/Application/Ports/ClientStore.php delete mode 100644 plugins/OAuth2/Application/Ports/DeviceCodeStore.php delete mode 100644 plugins/OAuth2/Application/Ports/RefreshTokenStore.php delete mode 100644 plugins/OAuth2/Application/Ports/ResourceOwnerVerifier.php delete mode 100644 plugins/OAuth2/Application/Ports/ScopeStore.php delete mode 100644 plugins/OAuth2/Application/Ports/UserInfoProvider.php delete mode 100644 plugins/OAuth2/Application/Services/AuthorizationRequest.php delete mode 100644 plugins/OAuth2/Application/Services/AuthorizationService.php delete mode 100644 plugins/OAuth2/Application/Services/DeviceService.php delete mode 100644 plugins/OAuth2/Application/Services/IntrospectionService.php delete mode 100644 plugins/OAuth2/Application/Services/ScopeRegistry.php delete mode 100644 plugins/OAuth2/Application/Services/ScopeValidator.php delete mode 100644 plugins/OAuth2/Application/Services/TokenIssuer.php delete mode 100644 plugins/OAuth2/Application/Services/TokenService.php delete mode 100644 plugins/OAuth2/Domain/Entities/AuthCode.php delete mode 100644 plugins/OAuth2/Domain/Entities/Client.php delete mode 100644 plugins/OAuth2/Domain/Entities/DeviceCode.php delete mode 100644 plugins/OAuth2/Domain/Entities/RefreshToken.php delete mode 100644 plugins/OAuth2/Domain/Exceptions/OAuthException.php delete mode 100644 plugins/OAuth2/Domain/ValueObjects/GrantType.php delete mode 100644 plugins/OAuth2/Domain/ValueObjects/Pkce.php delete mode 100644 plugins/OAuth2/Infrastructure/Cli/Concerns/TargetsTenant.php delete mode 100644 plugins/OAuth2/Infrastructure/Cli/CreateClientCommand.php delete mode 100644 plugins/OAuth2/Infrastructure/Cli/ListClientsCommand.php delete mode 100644 plugins/OAuth2/Infrastructure/Cli/PruneCommand.php delete mode 100644 plugins/OAuth2/Infrastructure/Cli/RevokeClientCommand.php delete mode 100644 plugins/OAuth2/Infrastructure/Cli/RotateClientSecretCommand.php delete mode 100644 plugins/OAuth2/Infrastructure/Cli/TenantConnections.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Concerns/ChecksOAuthAdmin.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Concerns/SpeaksOAuth.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/AdminController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/AdminUiController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/AuthorizationController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/AuthorizedTokenController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/ClientController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/DeviceController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/DeviceVerificationController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/DiscoveryController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/IntrospectionController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/JwksController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/ScopeController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/TokenController.php delete mode 100644 plugins/OAuth2/Infrastructure/Http/Controllers/UserInfoController.php delete mode 100644 plugins/OAuth2/Infrastructure/Identity/SubjectUserInfoProvider.php delete mode 100644 plugins/OAuth2/Infrastructure/Identity/UserResourceOwnerVerifier.php delete mode 100644 plugins/OAuth2/Infrastructure/Persistence/AuthCodeRepository.php delete mode 100644 plugins/OAuth2/Infrastructure/Persistence/ClientRepository.php delete mode 100644 plugins/OAuth2/Infrastructure/Persistence/DeviceCodeRepository.php delete mode 100644 plugins/OAuth2/Infrastructure/Persistence/RefreshTokenRepository.php delete mode 100644 plugins/OAuth2/Infrastructure/Persistence/ScopeRepository.php delete mode 100644 plugins/OAuth2/OAUTH2_GUIDE.pdf delete mode 100644 plugins/OAuth2/Provider.php delete mode 100644 plugins/OAuth2/README.md delete mode 100644 plugins/OAuth2/database/migrations/.gitkeep delete mode 100644 plugins/OAuth2/database/tenant-template/2026_06_27_000010_create_oauth_clients_table.php delete mode 100644 plugins/OAuth2/database/tenant-template/2026_06_27_000011_create_oauth_auth_codes_table.php delete mode 100644 plugins/OAuth2/database/tenant-template/2026_06_27_000012_create_oauth_refresh_tokens_table.php delete mode 100644 plugins/OAuth2/database/tenant-template/2026_06_27_000013_create_oauth_scopes_table.php delete mode 100644 plugins/OAuth2/database/tenant-template/2026_06_27_000014_create_oauth_device_codes_table.php delete mode 100644 plugins/OAuth2/database/tenant-template/2026_07_04_000001_add_owner_to_oauth_clients.php delete mode 100644 plugins/OAuth2/module.json delete mode 100644 plugins/OAuth2/resources/views/admin.php delete mode 100644 plugins/OAuth2/resources/views/consent.php delete mode 100644 plugins/OAuth2/resources/views/device.php delete mode 100644 plugins/OAuth2/ui/admin/Pages/OAuth2/Admin.tsx delete mode 100644 plugins/OAuth2/ui/admin/Pages/OAuth2/Consent.tsx delete mode 100644 plugins/OAuth2/ui/admin/Pages/OAuth2/Simulate.tsx delete mode 100644 plugins/OAuth2/ui/index.ts delete mode 100644 plugins/OAuth2/ui/ui.json delete mode 100644 plugins/Pageflow/API/Contracts/PageflowSharerContract.php delete mode 100644 plugins/Pageflow/Cli/PageflowTypesCommand.php delete mode 100644 plugins/Pageflow/Http/CallablePageflowSharer.php delete mode 100644 plugins/Pageflow/Http/CompositePageflowSharer.php delete mode 100644 plugins/Pageflow/Http/PageflowAuth.php delete mode 100644 plugins/Pageflow/Http/PageflowChannel.php delete mode 100644 plugins/Pageflow/Http/PageflowEndpointsController.php delete mode 100644 plugins/Pageflow/Http/PageflowPage.php delete mode 100644 plugins/Pageflow/Http/PageflowResponder.php delete mode 100644 plugins/Pageflow/Http/PageflowShares.php delete mode 100644 plugins/Pageflow/Http/PageflowStage.php delete mode 100644 plugins/Pageflow/Http/PageflowStream.php delete mode 100644 plugins/Pageflow/Http/RegistryPageflowSharer.php delete mode 100644 plugins/Pageflow/Provider.php delete mode 100644 plugins/Pageflow/README.md delete mode 100644 plugins/Pageflow/Support/helpers.php delete mode 100644 plugins/Pageflow/module.json delete mode 100644 plugins/Pageflow/resources/layouts/app.php delete mode 100644 plugins/Pageflow/ui/PAGEFLOW_GUIDE.pdf delete mode 100644 plugins/Pageflow/ui/PAGEFLOW_TUTORIAL.pdf delete mode 100644 plugins/Pageflow/ui/PAGEFLOW_UI_REFERENCE.pdf delete mode 100644 plugins/Pageflow/ui/PAGEFLOW_URL_PRESERVATION.pdf delete mode 100644 plugins/Pageflow/ui/PAGEFLOW_USAGE.pdf delete mode 100644 plugins/Pageflow/ui/core/csrf.ts delete mode 100644 plugins/Pageflow/ui/core/csrfRetry.ts delete mode 100644 plugins/Pageflow/ui/core/encryption.ts delete mode 100644 plugins/Pageflow/ui/core/eventHandler/events.ts delete mode 100644 plugins/Pageflow/ui/core/eventHandler/index.ts delete mode 100644 plugins/Pageflow/ui/core/files.ts delete mode 100644 plugins/Pageflow/ui/core/formData.ts delete mode 100644 plugins/Pageflow/ui/core/head/index.ts delete mode 100644 plugins/Pageflow/ui/core/history.ts delete mode 100644 plugins/Pageflow/ui/core/http/request.ts delete mode 100644 plugins/Pageflow/ui/core/http/requestParams.ts delete mode 100644 plugins/Pageflow/ui/core/http/requestStream.ts delete mode 100644 plugins/Pageflow/ui/core/http/response.ts delete mode 100644 plugins/Pageflow/ui/core/index.ts delete mode 100644 plugins/Pageflow/ui/core/initialVisit.ts delete mode 100644 plugins/Pageflow/ui/core/modal.ts delete mode 100644 plugins/Pageflow/ui/core/navigationType.ts delete mode 100644 plugins/Pageflow/ui/core/objectUtils.ts delete mode 100644 plugins/Pageflow/ui/core/page.ts delete mode 100644 plugins/Pageflow/ui/core/poll/index.ts delete mode 100644 plugins/Pageflow/ui/core/poll/polls.ts delete mode 100644 plugins/Pageflow/ui/core/precognition.ts delete mode 100644 plugins/Pageflow/ui/core/prefetched.ts delete mode 100644 plugins/Pageflow/ui/core/progress-component.ts delete mode 100644 plugins/Pageflow/ui/core/progress.ts delete mode 100644 plugins/Pageflow/ui/core/queue.ts delete mode 100644 plugins/Pageflow/ui/core/reactive.ts delete mode 100644 plugins/Pageflow/ui/core/router.ts delete mode 100644 plugins/Pageflow/ui/core/scroll.ts delete mode 100644 plugins/Pageflow/ui/core/server.ts delete mode 100644 plugins/Pageflow/ui/core/serviceWorker.ts delete mode 100644 plugins/Pageflow/ui/core/sessionStorage.ts delete mode 100644 plugins/Pageflow/ui/core/shouldIntercept.ts delete mode 100644 plugins/Pageflow/ui/core/time.ts delete mode 100644 plugins/Pageflow/ui/core/types.ts delete mode 100644 plugins/Pageflow/ui/core/url.ts delete mode 100644 plugins/Pageflow/ui/index.ts delete mode 100644 plugins/Pageflow/ui/pageflow-sw.js delete mode 100644 plugins/Pageflow/ui/react/App.tsx delete mode 100644 plugins/Pageflow/ui/react/Can.tsx delete mode 100644 plugins/Pageflow/ui/react/Deferred.tsx delete mode 100644 plugins/Pageflow/ui/react/Form.tsx delete mode 100644 plugins/Pageflow/ui/react/Head.tsx delete mode 100644 plugins/Pageflow/ui/react/HeadContext.tsx delete mode 100644 plugins/Pageflow/ui/react/Link.tsx delete mode 100644 plugins/Pageflow/ui/react/PageContext.tsx delete mode 100644 plugins/Pageflow/ui/react/WhenVisible.tsx delete mode 100644 plugins/Pageflow/ui/react/createPageflowApp.tsx delete mode 100644 plugins/Pageflow/ui/react/index.tsx delete mode 100644 plugins/Pageflow/ui/react/server.ts delete mode 100644 plugins/Pageflow/ui/react/useAuth.tsx delete mode 100644 plugins/Pageflow/ui/react/useDirtyGuard.tsx delete mode 100644 plugins/Pageflow/ui/react/useFlushOnIdentityChange.tsx delete mode 100644 plugins/Pageflow/ui/react/useForm.tsx delete mode 100644 plugins/Pageflow/ui/react/usePage.tsx delete mode 100644 plugins/Pageflow/ui/react/usePoll.tsx delete mode 100644 plugins/Pageflow/ui/react/usePrecognition.tsx delete mode 100644 plugins/Pageflow/ui/react/usePrefetch.tsx delete mode 100644 plugins/Pageflow/ui/react/useReactiveProps.tsx delete mode 100644 plugins/Pageflow/ui/react/useRemember.tsx delete mode 100644 plugins/Pageflow/ui/tests/csrf.test.ts delete mode 100644 plugins/Pageflow/ui/tests/precognition.test.ts delete mode 100644 plugins/Pageflow/ui/tests/queue.test.ts delete mode 100644 plugins/Pageflow/ui/tests/reactive.test.ts delete mode 100644 plugins/Pageflow/ui/tests/resolvePageComponent.test.ts delete mode 100644 plugins/Pageflow/ui/tests/sameOrigin.test.ts delete mode 100644 plugins/Pageflow/ui/tests/url.test.ts delete mode 100644 plugins/Pageflow/ui/ui.json delete mode 100644 plugins/Pageflow/ui/vitest.config.ts delete mode 100644 plugins/RedisCache/Infrastructure/RedisCacheAdapter.php delete mode 100644 plugins/RedisCache/Infrastructure/RedisConnection.php delete mode 100644 plugins/RedisCache/Infrastructure/RedisLock.php delete mode 100644 plugins/RedisCache/Infrastructure/RedisQueueAdapter.php delete mode 100644 plugins/RedisCache/Provider.php delete mode 100644 plugins/RedisCache/module.json delete mode 100644 plugins/SecurityFilters/Infrastructure/Http/Stages/ApiRateLimitStage.php delete mode 100644 plugins/SecurityFilters/Infrastructure/Http/Stages/HmacSignedStage.php delete mode 100644 plugins/SecurityFilters/Infrastructure/Http/Stages/RequireAuthStage.php delete mode 100644 plugins/SecurityFilters/Infrastructure/Http/Stages/SecurityHeadersStage.php delete mode 100644 plugins/SecurityFilters/Infrastructure/Http/Stages/ShieldStage.php delete mode 100644 plugins/SecurityFilters/Provider.php delete mode 100644 plugins/SecurityFilters/module.json delete mode 100644 plugins/Session/Infrastructure/Handlers/ArraySessionHandler.php delete mode 100644 plugins/Session/Infrastructure/Handlers/Contracts/CookieBackedHandler.php delete mode 100644 plugins/Session/Infrastructure/Handlers/CookieSessionConfig.php delete mode 100644 plugins/Session/Infrastructure/Handlers/CookieSessionHandler.php delete mode 100644 plugins/Session/Infrastructure/Handlers/FileSessionHandler.php delete mode 100644 plugins/Session/Infrastructure/Http/StartSessionStage.php delete mode 100644 plugins/Session/Infrastructure/Store.php delete mode 100644 plugins/Session/Provider.php delete mode 100644 plugins/Session/module.json delete mode 100644 plugins/Settings/API/Contracts/SettingsServiceContract.php delete mode 100644 plugins/Settings/API/DTOs/CompanySettingsDTO.php delete mode 100644 plugins/Settings/API/DTOs/ContactSettingsDTO.php delete mode 100644 plugins/Settings/API/DTOs/EmailProviderSettingsDTO.php delete mode 100644 plugins/Settings/API/DTOs/EmailSettingsDTO.php delete mode 100644 plugins/Settings/API/DTOs/SystemSettingsDTO.php delete mode 100644 plugins/Settings/Application/Services/SettingsService.php delete mode 100644 plugins/Settings/Domain/Entities/TenantSettings.php delete mode 100644 plugins/Settings/Domain/ValueObjects/SettingsSection.php delete mode 100644 plugins/Settings/Infrastructure/Http/SettingsController.php delete mode 100644 plugins/Settings/Infrastructure/Persistence/SettingsRepository.php delete mode 100644 plugins/Settings/Provider.php delete mode 100644 plugins/Settings/README.md delete mode 100644 plugins/Settings/database/migrations/2026_06_25_000001_create_tenant_settings_company_table.php delete mode 100644 plugins/Settings/database/migrations/2026_06_25_000002_create_tenant_settings_contact_table.php delete mode 100644 plugins/Settings/database/migrations/2026_06_25_000003_create_tenant_settings_email_table.php delete mode 100644 plugins/Settings/database/migrations/2026_06_25_000004_create_tenant_settings_email_providers_table.php delete mode 100644 plugins/Settings/database/migrations/2026_06_25_000005_create_tenant_settings_system_table.php delete mode 100644 plugins/Settings/module.json delete mode 100644 plugins/SiteSEO/API/Contracts/SeoServiceContract.php delete mode 100644 plugins/SiteSEO/API/IntegrationEvents/UrlPublishedIntegrationEvent.php delete mode 100644 plugins/SiteSEO/Application/Jobs/IndexNowJob.php delete mode 100644 plugins/SiteSEO/Application/Listeners/EnqueueIndexNowListener.php delete mode 100644 plugins/SiteSEO/Application/Services/SeoService.php delete mode 100644 plugins/SiteSEO/BaseObject.php delete mode 100644 plugins/SiteSEO/Channel.php delete mode 100644 plugins/SiteSEO/Exceptions/SeoException.php delete mode 100644 plugins/SiteSEO/Exceptions/SitemapException.php delete mode 100644 plugins/SiteSEO/Helpers/Escape.php delete mode 100644 plugins/SiteSEO/Indexing.php delete mode 100644 plugins/SiteSEO/Infrastructure/Gateways/SearchEngineGateway.php delete mode 100644 plugins/SiteSEO/Infrastructure/Http/SeoController.php delete mode 100644 plugins/SiteSEO/Interfaces/SchemaInterface.php delete mode 100644 plugins/SiteSEO/Interfaces/SeoInterface.php delete mode 100644 plugins/SiteSEO/Interfaces/SitemapBuilderInterface.php delete mode 100644 plugins/SiteSEO/Interfaces/SitemapIndexInterface.php delete mode 100644 plugins/SiteSEO/Interfaces/SitemapInterface.php delete mode 100644 plugins/SiteSEO/OpenGraph.php delete mode 100644 plugins/SiteSEO/Ping.php delete mode 100644 plugins/SiteSEO/Property.php delete mode 100644 plugins/SiteSEO/Provider.php delete mode 100644 plugins/SiteSEO/RobotsTxtEditor.php delete mode 100644 plugins/SiteSEO/RobotsTxtValidator.php delete mode 100644 plugins/SiteSEO/Schema.php delete mode 100644 plugins/SiteSEO/Schema/Thing.php delete mode 100644 plugins/SiteSEO/Schema/Things/ContactPoint.php delete mode 100644 plugins/SiteSEO/Schema/Things/Offer.php delete mode 100644 plugins/SiteSEO/Schema/Things/Organization.php delete mode 100644 plugins/SiteSEO/Schema/Things/Product.php delete mode 100644 plugins/SiteSEO/Schema/Things/WebPage.php delete mode 100644 plugins/SiteSEO/Sitemap.php delete mode 100644 plugins/SiteSEO/Sitemap/LinksBuilder.php delete mode 100644 plugins/SiteSEO/Sitemap/NewsBuilder.php delete mode 100644 plugins/SiteSEO/Sitemap/SitemapBuilder.php delete mode 100644 plugins/SiteSEO/Sitemap/SitemapIndex.php delete mode 100644 plugins/SiteSEO/Sitemap/SitemapParser.php delete mode 100644 plugins/SiteSEO/StructuredProperties/Audio.php delete mode 100644 plugins/SiteSEO/StructuredProperties/Image.php delete mode 100644 plugins/SiteSEO/StructuredProperties/StructuredProperty.php delete mode 100644 plugins/SiteSEO/StructuredProperties/Video.php delete mode 100644 plugins/SiteSEO/Support/ConditionalCallProxy.php delete mode 100644 plugins/SiteSEO/Support/HasConditionalCalls.php delete mode 100644 plugins/SiteSEO/Twitter.php delete mode 100644 plugins/SiteSEO/TwitterProperty.php delete mode 100644 plugins/SiteSEO/TwitterType.php delete mode 100644 plugins/SiteSEO/Type.php delete mode 100644 plugins/SiteSEO/Types/Article.php delete mode 100644 plugins/SiteSEO/Types/Book.php delete mode 100644 plugins/SiteSEO/Types/Music/Album.php delete mode 100644 plugins/SiteSEO/Types/Music/Playlist.php delete mode 100644 plugins/SiteSEO/Types/Music/RadioStation.php delete mode 100644 plugins/SiteSEO/Types/Music/Song.php delete mode 100644 plugins/SiteSEO/Types/Profile.php delete mode 100644 plugins/SiteSEO/Types/Twitter/App.php delete mode 100644 plugins/SiteSEO/Types/Twitter/Player.php delete mode 100644 plugins/SiteSEO/Types/Twitter/Summary.php delete mode 100644 plugins/SiteSEO/Types/Twitter/SummaryLargeImage.php delete mode 100644 plugins/SiteSEO/Types/Video/Episode.php delete mode 100644 plugins/SiteSEO/Types/Video/Movie.php delete mode 100644 plugins/SiteSEO/Types/Video/Other.php delete mode 100644 plugins/SiteSEO/Types/Video/TvShow.php delete mode 100644 plugins/SiteSEO/Types/Website.php delete mode 100644 plugins/SiteSEO/XRobotsTag.php delete mode 100644 plugins/SiteSEO/module.json delete mode 100644 plugins/SocialAuth/API/Contracts/SocialAuthServiceContract.php delete mode 100644 plugins/SocialAuth/Application/Services/SocialAuthService.php delete mode 100644 plugins/SocialAuth/Application/Services/SocialLoginService.php delete mode 100644 plugins/SocialAuth/Infrastructure/Gateways/ProviderTokenGateway.php delete mode 100644 plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php delete mode 100644 plugins/SocialAuth/Infrastructure/Persistence/SocialIdentityRepository.php delete mode 100644 plugins/SocialAuth/Provider.php delete mode 100644 plugins/SocialAuth/Socialite/AbstractUser.php delete mode 100644 plugins/SocialAuth/Socialite/Http/RedirectResponse.php delete mode 100644 plugins/SocialAuth/Socialite/Http/Request.php delete mode 100644 plugins/SocialAuth/Socialite/Http/Session.php delete mode 100644 plugins/SocialAuth/Socialite/One/AbstractProvider.php delete mode 100644 plugins/SocialAuth/Socialite/One/MissingTemporaryCredentialsException.php delete mode 100644 plugins/SocialAuth/Socialite/One/MissingVerifierException.php delete mode 100644 plugins/SocialAuth/Socialite/One/TwitterProvider.php delete mode 100644 plugins/SocialAuth/Socialite/One/User.php delete mode 100644 plugins/SocialAuth/Socialite/Ports/Factory.php delete mode 100644 plugins/SocialAuth/Socialite/Ports/Provider.php delete mode 100644 plugins/SocialAuth/Socialite/Ports/User.php delete mode 100644 plugins/SocialAuth/Socialite/SocialiteManager.php delete mode 100644 plugins/SocialAuth/Socialite/Support/Config.php delete mode 100644 plugins/SocialAuth/Socialite/Support/Manager.php delete mode 100644 plugins/SocialAuth/Socialite/Support/helpers.php delete mode 100644 plugins/SocialAuth/Socialite/Two/AbstractProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/BitbucketProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/FacebookProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/GithubProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/GitlabProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/GoogleProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/InvalidStateException.php delete mode 100644 plugins/SocialAuth/Socialite/Two/LinkedInOpenIdProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/LinkedInProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/ProviderInterface.php delete mode 100644 plugins/SocialAuth/Socialite/Two/SlackOpenIdProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/SlackProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/Token.php delete mode 100644 plugins/SocialAuth/Socialite/Two/TwitterProvider.php delete mode 100644 plugins/SocialAuth/Socialite/Two/User.php delete mode 100644 plugins/SocialAuth/Socialite/Two/XProvider.php delete mode 100644 plugins/SocialAuth/database/migrations/.gitkeep delete mode 100644 plugins/SocialAuth/database/tenant-template/2026_07_12_000001_create_social_identities_table.php delete mode 100644 plugins/SocialAuth/module.json delete mode 100644 plugins/Storage/Infrastructure/LocalStorageAdapter.php delete mode 100644 plugins/Storage/Infrastructure/S3StorageAdapter.php delete mode 100644 plugins/Storage/Provider.php delete mode 100644 plugins/Storage/Support/helpers.php delete mode 100644 plugins/Storage/config/storage.php delete mode 100644 plugins/Storage/module.json delete mode 100644 plugins/Tenancy/API/Contracts/InvitationServiceContract.php delete mode 100644 plugins/Tenancy/API/Contracts/MembershipServiceContract.php delete mode 100644 plugins/Tenancy/API/Contracts/TenantAdminServiceContract.php delete mode 100644 plugins/Tenancy/API/Contracts/TenantConnectionResolverContract.php delete mode 100644 plugins/Tenancy/API/Contracts/TenantHostRegistryContract.php delete mode 100644 plugins/Tenancy/API/Contracts/TenantHostServiceContract.php delete mode 100644 plugins/Tenancy/API/Contracts/TenantRegistryContract.php delete mode 100644 plugins/Tenancy/API/DTOs/HostVerificationInstructions.php delete mode 100644 plugins/Tenancy/API/DTOs/HostVerificationResult.php delete mode 100644 plugins/Tenancy/API/DTOs/InvitationResult.php delete mode 100644 plugins/Tenancy/API/DTOs/TenantDetail.php delete mode 100644 plugins/Tenancy/API/DTOs/TenantSelection.php delete mode 100644 plugins/Tenancy/API/DTOs/TenantSummary.php delete mode 100644 plugins/Tenancy/API/IntegrationEvents/HostUnverifiedIntegrationEvent.php delete mode 100644 plugins/Tenancy/API/IntegrationEvents/HostVerifiedIntegrationEvent.php delete mode 100644 plugins/Tenancy/Application/Listeners/AssignTenantMembershipOnUserRegistered.php delete mode 100644 plugins/Tenancy/Application/Ports/AuditReader.php delete mode 100644 plugins/Tenancy/Application/Ports/AuditSink.php delete mode 100644 plugins/Tenancy/Application/Ports/AuditWriter.php delete mode 100644 plugins/Tenancy/Application/Ports/DnsResolver.php delete mode 100644 plugins/Tenancy/Application/Ports/InvitationStore.php delete mode 100644 plugins/Tenancy/Application/Ports/MembershipReader.php delete mode 100644 plugins/Tenancy/Application/Ports/MembershipWriter.php delete mode 100644 plugins/Tenancy/Application/Ports/TenantHostStore.php delete mode 100644 plugins/Tenancy/Application/Ports/TenantProvisioner.php delete mode 100644 plugins/Tenancy/Application/Ports/TenantWriteStore.php delete mode 100644 plugins/Tenancy/Application/Services/AuditService.php delete mode 100644 plugins/Tenancy/Application/Services/InvitationService.php delete mode 100644 plugins/Tenancy/Application/Services/MembershipService.php delete mode 100644 plugins/Tenancy/Application/Services/TenantAdminService.php delete mode 100644 plugins/Tenancy/Application/Services/TenantHostService.php delete mode 100644 plugins/Tenancy/Domain/Entities/AuditEntry.php delete mode 100644 plugins/Tenancy/Domain/Entities/Invitation.php delete mode 100644 plugins/Tenancy/Domain/Entities/Membership.php delete mode 100644 plugins/Tenancy/Domain/Entities/Tenant.php delete mode 100644 plugins/Tenancy/Domain/Entities/TenantHost.php delete mode 100644 plugins/Tenancy/Domain/Exceptions/HostConflictException.php delete mode 100644 plugins/Tenancy/Domain/Exceptions/HostNotFoundException.php delete mode 100644 plugins/Tenancy/Domain/Exceptions/HostQuotaExceededException.php delete mode 100644 plugins/Tenancy/Domain/Exceptions/InvalidHostnameException.php delete mode 100644 plugins/Tenancy/Domain/Exceptions/InvalidInvitationException.php delete mode 100644 plugins/Tenancy/Domain/Exceptions/NotAMemberException.php delete mode 100644 plugins/Tenancy/Domain/Exceptions/TenantUnavailableException.php delete mode 100644 plugins/Tenancy/Domain/Exceptions/UnknownTenantException.php delete mode 100644 plugins/Tenancy/Domain/ValueObjects/HostStatus.php delete mode 100644 plugins/Tenancy/Domain/ValueObjects/Hostname.php delete mode 100644 plugins/Tenancy/Domain/ValueObjects/InvitationStatus.php delete mode 100644 plugins/Tenancy/Domain/ValueObjects/MembershipStatus.php delete mode 100644 plugins/Tenancy/Domain/ValueObjects/TenantStatus.php delete mode 100644 plugins/Tenancy/Infrastructure/Cli/AddTenantHostCommand.php delete mode 100644 plugins/Tenancy/Infrastructure/Cli/Concerns/ManagesTenantDatabase.php delete mode 100644 plugins/Tenancy/Infrastructure/Cli/CreateTenantCommand.php delete mode 100644 plugins/Tenancy/Infrastructure/Cli/DeleteTenantCommand.php delete mode 100644 plugins/Tenancy/Infrastructure/Cli/MigrateTenantsCommand.php delete mode 100644 plugins/Tenancy/Infrastructure/Cli/RememberTenantCommand.php delete mode 100644 plugins/Tenancy/Infrastructure/Dns/SystemDnsResolver.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Controllers/InvitationController.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Controllers/TenantAdminController.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Controllers/TenantController.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Controllers/TenantHostController.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Controllers/TenantPageController.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Identification/ClaimTenantIdentifier.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Identification/DomainTenantIdentifier.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Identification/HostTenantIdentifier.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Identification/TenantIdentifier.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Stages/RequireTenantStage.php delete mode 100644 plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php delete mode 100644 plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php delete mode 100644 plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php delete mode 100644 plugins/Tenancy/Infrastructure/Persistence/InvitationRepository.php delete mode 100644 plugins/Tenancy/Infrastructure/Persistence/MembershipRepository.php delete mode 100644 plugins/Tenancy/Infrastructure/Persistence/TenantAdminRepository.php delete mode 100644 plugins/Tenancy/Infrastructure/Persistence/TenantHostRegistry.php delete mode 100644 plugins/Tenancy/Infrastructure/Persistence/TenantHostRepository.php delete mode 100644 plugins/Tenancy/Infrastructure/Persistence/TenantRegistry.php delete mode 100644 plugins/Tenancy/Infrastructure/Provisioning/DdlTenantProvisioner.php delete mode 100644 plugins/Tenancy/Infrastructure/TenantConnectionResolver.php delete mode 100644 plugins/Tenancy/Provider.php delete mode 100644 plugins/Tenancy/README.md delete mode 100644 plugins/Tenancy/Support/TenantsFile.php delete mode 100644 plugins/Tenancy/Support/Token.php delete mode 100644 plugins/Tenancy/database/migrations/2026_06_22_000001_create_tenants_table.php delete mode 100644 plugins/Tenancy/database/migrations/2026_06_22_000002_create_user_tenants_table.php delete mode 100644 plugins/Tenancy/database/migrations/2026_06_22_000003_create_tenant_invitations_table.php delete mode 100644 plugins/Tenancy/database/migrations/2026_06_22_000006_create_tenant_hosts_table.php delete mode 100644 plugins/Tenancy/database/tenant-template/.gitkeep delete mode 100644 plugins/Tenancy/module.json delete mode 100644 plugins/Tenancy/resources/views/hosts/index.php delete mode 100644 plugins/Tenancy/resources/views/layouts/app.php delete mode 100644 plugins/Tenancy/resources/views/tenants/create.php delete mode 100644 plugins/Tenancy/resources/views/tenants/edit.php delete mode 100644 plugins/Tenancy/resources/views/tenants/index.php delete mode 100644 plugins/Tenancy/resources/views/tenants/manage.php delete mode 100644 plugins/Tenancy/ui/README.md delete mode 100644 plugins/Tenancy/ui/admin/Pages/Tenant/Create.tsx delete mode 100644 plugins/Tenancy/ui/admin/Pages/Tenant/Edit.tsx delete mode 100644 plugins/Tenancy/ui/admin/Pages/Tenant/Manage.tsx delete mode 100644 plugins/Tenancy/ui/components/StatusBadge.tsx delete mode 100644 plugins/Tenancy/ui/components/TenantBadge.tsx delete mode 100644 plugins/Tenancy/ui/index.ts delete mode 100644 plugins/Tenancy/ui/lib/client.ts delete mode 100644 plugins/Tenancy/ui/site/Pages/Tenant/Hosts.tsx delete mode 100644 plugins/Tenancy/ui/site/Pages/Tenant/Index.tsx delete mode 100644 plugins/Tenancy/ui/ui.json delete mode 100644 plugins/User/API/Contracts/.gitkeep delete mode 100644 plugins/User/API/Contracts/TenantProfileReaderContract.php delete mode 100644 plugins/User/API/Contracts/UserServiceContract.php delete mode 100644 plugins/User/API/DTOs/FeedbackPage.php delete mode 100644 plugins/User/API/DTOs/ListFeedbackQuery.php delete mode 100644 plugins/User/API/DTOs/ListUsersQuery.php delete mode 100644 plugins/User/API/DTOs/RegisterUserDTO.php delete mode 100644 plugins/User/API/DTOs/SubmitFeedbackDTO.php delete mode 100644 plugins/User/API/DTOs/UpdateNotificationPreferencesDTO.php delete mode 100644 plugins/User/API/DTOs/UpdatePreferencesDTO.php delete mode 100644 plugins/User/API/DTOs/UpdatePrivacyDTO.php delete mode 100644 plugins/User/API/DTOs/UpdateProfileDTO.php delete mode 100644 plugins/User/API/DTOs/UpdateUserDTO.php delete mode 100644 plugins/User/API/DTOs/UserDTO.php delete mode 100644 plugins/User/API/DTOs/UserPage.php delete mode 100644 plugins/User/API/DTOs/VerifyEmailDTO.php delete mode 100644 plugins/User/API/DTOs/VerifyEmailResult.php delete mode 100644 plugins/User/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php delete mode 100644 plugins/User/API/IntegrationEvents/GenericIntegrationEvent.php delete mode 100644 plugins/User/API/IntegrationEvents/UserDeletedIntegrationEvent.php delete mode 100644 plugins/User/API/IntegrationEvents/UserRegisteredIntegrationEvent.php delete mode 100644 plugins/User/API/IntegrationEvents/UserUpdatedIntegrationEvent.php delete mode 100644 plugins/User/Application/Ports/BreachChecker.php delete mode 100644 plugins/User/Application/Ports/FeedbackStore.php delete mode 100644 plugins/User/Application/Ports/OutboxPort.php delete mode 100644 plugins/User/Application/Ports/UserStore.php delete mode 100644 plugins/User/Application/Services/.gitkeep delete mode 100644 plugins/User/Application/Services/FeedbackService.php delete mode 100644 plugins/User/Application/Services/OutboxRelayService.php delete mode 100644 plugins/User/Application/Services/TenantProfileProvisioner.php delete mode 100644 plugins/User/Application/Services/UserService.php delete mode 100644 plugins/User/Application/Services/UserSettingsService.php delete mode 100644 plugins/User/Domain/.gitkeep delete mode 100644 plugins/User/Domain/Entities/FeedbackEntry.php delete mode 100644 plugins/User/Domain/Entities/User.php delete mode 100644 plugins/User/Domain/Entities/UserNotificationPreferences.php delete mode 100644 plugins/User/Domain/Entities/UserPreferences.php delete mode 100644 plugins/User/Domain/Entities/UserPrivacySettings.php delete mode 100644 plugins/User/Domain/Entities/UserProfile.php delete mode 100644 plugins/User/Domain/Events/UserDeletedDomainEvent.php delete mode 100644 plugins/User/Domain/Events/UserRegisteredDomainEvent.php delete mode 100644 plugins/User/Domain/Events/UserUpdatedDomainEvent.php delete mode 100644 plugins/User/Domain/Exceptions/DuplicateUserException.php delete mode 100644 plugins/User/Domain/ValueObjects/Email.php delete mode 100644 plugins/User/Domain/ValueObjects/FeedbackCategory.php delete mode 100644 plugins/User/Domain/ValueObjects/FeedbackId.php delete mode 100644 plugins/User/Domain/ValueObjects/FeedbackMessage.php delete mode 100644 plugins/User/Domain/ValueObjects/FeedbackRating.php delete mode 100644 plugins/User/Domain/ValueObjects/FeedbackStatus.php delete mode 100644 plugins/User/Domain/ValueObjects/PasswordPolicy.php delete mode 100644 plugins/User/Domain/ValueObjects/ProfileVisibility.php delete mode 100644 plugins/User/Domain/ValueObjects/Theme.php delete mode 100644 plugins/User/Domain/ValueObjects/Ulid.php delete mode 100644 plugins/User/Domain/ValueObjects/UserId.php delete mode 100644 plugins/User/Domain/ValueObjects/Username.php delete mode 100644 plugins/User/Infrastructure/Audit/AuditLogger.php delete mode 100644 plugins/User/Infrastructure/Cli/RelayUserOutboxCommand.php delete mode 100644 plugins/User/Infrastructure/Gateways/NullBreachChecker.php delete mode 100644 plugins/User/Infrastructure/Gateways/PwnedPasswordGateway.php delete mode 100644 plugins/User/Infrastructure/Http/.gitkeep delete mode 100644 plugins/User/Infrastructure/Http/Controllers/FeedbackController.php delete mode 100644 plugins/User/Infrastructure/Http/Controllers/UserController.php delete mode 100644 plugins/User/Infrastructure/Http/Controllers/UserFlowController.php delete mode 100644 plugins/User/Infrastructure/Http/Controllers/UserPageController.php delete mode 100644 plugins/User/Infrastructure/Http/Controllers/UserSettingsController.php delete mode 100644 plugins/User/Infrastructure/Listeners/ProvisionTenantProfileListener.php delete mode 100644 plugins/User/Infrastructure/Outbox/OutboxRelay.php delete mode 100644 plugins/User/Infrastructure/Outbox/OutboxWriter.php delete mode 100644 plugins/User/Infrastructure/Persistence/FeedbackRepository.php delete mode 100644 plugins/User/Infrastructure/Persistence/OutboxRepository.php delete mode 100644 plugins/User/Infrastructure/Persistence/UserRepository.php delete mode 100644 plugins/User/Infrastructure/Persistence/UserSettingsRepository.php delete mode 100644 plugins/User/Provider.php delete mode 100644 plugins/User/README.md delete mode 100644 plugins/User/User-Plugin.pdf delete mode 100644 plugins/User/config/user.php delete mode 100644 plugins/User/database/factories/UserFactory.php delete mode 100644 plugins/User/database/migrations/2026_01_01_000000_create_user_table.php delete mode 100644 plugins/User/database/migrations/2026_01_01_000001_create_user_outbox_table.php delete mode 100644 plugins/User/database/migrations/2026_01_01_000002_add_email_verification_token_to_users.php delete mode 100644 plugins/User/database/migrations/2026_07_19_000001_add_platform_admin_to_users.php delete mode 100644 plugins/User/database/seeders/UserSeeder.php delete mode 100644 plugins/User/database/tenant-template/2026_06_29_000001_create_user_profiles_table.php delete mode 100644 plugins/User/database/tenant-template/2026_06_29_000002_create_user_privacy_settings_table.php delete mode 100644 plugins/User/database/tenant-template/2026_06_29_000003_create_user_preferences_table.php delete mode 100644 plugins/User/database/tenant-template/2026_06_29_000004_create_user_notification_preferences_table.php delete mode 100644 plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php delete mode 100644 plugins/User/module.json delete mode 100644 plugins/User/resources/views/account/feedback.php delete mode 100644 plugins/User/resources/views/account/settings.php delete mode 100644 plugins/User/resources/views/account/verify.php delete mode 100644 plugins/User/resources/views/emails/verify.php delete mode 100644 plugins/User/resources/views/layouts/app.php delete mode 100644 plugins/User/resources/views/user.php delete mode 100644 plugins/User/resources/views/users/create.php delete mode 100644 plugins/User/resources/views/users/edit.php delete mode 100644 plugins/User/resources/views/users/index.php delete mode 100644 plugins/User/resources/views/users/show.php delete mode 100644 plugins/User/ui/README.md delete mode 100644 plugins/User/ui/admin/Pages/User/Index.tsx delete mode 100644 plugins/User/ui/admin/Pages/User/Show.tsx delete mode 100644 plugins/User/ui/components/UserBadge.tsx delete mode 100644 plugins/User/ui/index.ts delete mode 100644 plugins/User/ui/site/Pages/User/Profile.tsx delete mode 100644 plugins/User/ui/site/Pages/User/Register.tsx delete mode 100644 plugins/User/ui/site/Pages/User/VerifyEmail.tsx delete mode 100644 plugins/User/ui/ui.json delete mode 100644 plugins/Validation/AbstractDto.php delete mode 100644 plugins/Validation/Provider.php delete mode 100644 plugins/Validation/README.md delete mode 100644 plugins/Validation/Rules/CommonRules.php delete mode 100644 plugins/Validation/Rules/FinancialRules.php delete mode 100644 plugins/Validation/Validator.php delete mode 100644 plugins/Validation/config/validation.php delete mode 100644 plugins/Validation/module.json delete mode 100644 plugins/View/API/Contracts/ViewDecoratorContract.php delete mode 100644 plugins/View/API/Contracts/ViewRendererContract.php delete mode 100644 plugins/View/Exceptions/ViewException.php delete mode 100644 plugins/View/Infrastructure/PhpViewRenderer.php delete mode 100644 plugins/View/Infrastructure/SidebarManager.php delete mode 100644 plugins/View/Provider.php delete mode 100644 plugins/View/module.json delete mode 100644 plugins/ViteManifest/API/Contracts/ViteContract.php delete mode 100644 plugins/ViteManifest/Exceptions/ViteManifestNotFoundException.php delete mode 100644 plugins/ViteManifest/Infrastructure/ManifestReader.php delete mode 100644 plugins/ViteManifest/Infrastructure/Vite.php delete mode 100644 plugins/ViteManifest/Provider.php delete mode 100644 plugins/ViteManifest/README.md delete mode 100644 plugins/ViteManifest/Support/Html.php delete mode 100644 plugins/ViteManifest/Support/helpers.php delete mode 100644 plugins/ViteManifest/ViteConfig.php delete mode 100644 plugins/ViteManifest/ViteFactory.php delete mode 100644 plugins/ViteManifest/module.json diff --git a/composer.json b/composer.json index a588ae0..cfe6bd7 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,35 @@ "league/flysystem": "^3.34", "league/flysystem-aws-s3-v3": "^3.34", "symfony/http-foundation": "^8.1", - "symfony/mime": "^8.1" + "symfony/mime": "^8.1", + "alfacode-team/hkm-plugin-audit": "@dev", + "alfacode-team/hkm-plugin-auth": "@dev", + "alfacode-team/hkm-plugin-authorization": "@dev", + "alfacode-team/hkm-plugin-commands": "@dev", + "alfacode-team/hkm-plugin-cookie": "@dev", + "alfacode-team/hkm-plugin-crypto": "@dev", + "alfacode-team/hkm-plugin-database": "@dev", + "alfacode-team/hkm-plugin-dev-tools": "@dev", + "alfacode-team/hkm-plugin-edge": "@dev", + "alfacode-team/hkm-plugin-feedback": "@dev", + "alfacode-team/hkm-plugin-http-client": "@dev", + "alfacode-team/hkm-plugin-i18n": "@dev", + "alfacode-team/hkm-plugin-logger": "@dev", + "alfacode-team/hkm-plugin-mail": "@dev", + "alfacode-team/hkm-plugin-oauth2": "@dev", + "alfacode-team/hkm-plugin-pageflow": "@dev", + "alfacode-team/hkm-plugin-redis-cache": "@dev", + "alfacode-team/hkm-plugin-security-filters": "@dev", + "alfacode-team/hkm-plugin-session": "@dev", + "alfacode-team/hkm-plugin-settings": "@dev", + "alfacode-team/hkm-plugin-siteseo": "@dev", + "alfacode-team/hkm-plugin-social-auth": "@dev", + "alfacode-team/hkm-plugin-storage": "@dev", + "alfacode-team/hkm-plugin-tenancy": "@dev", + "alfacode-team/hkm-plugin-user": "@dev", + "alfacode-team/hkm-plugin-validation": "@dev", + "alfacode-team/hkm-plugin-view": "@dev", + "alfacode-team/hkm-plugin-vitemanifest": "@dev" }, "require-dev": { "phpunit/phpunit": "13.2.x-dev", @@ -57,6 +85,13 @@ "composer/composer": "^2.7" }, "repositories": [ + { + "type": "path", + "url": "../plugins/hkm-plugin-*", + "options": { + "symlink": true + } + }, { "type": "path", "url": "modules/http" @@ -82,17 +117,10 @@ "psr-4": { "AlfacodeTeam\\PhpServicePlatform\\": "src/", "AlfacodeTeam\\PhpServicePlatform\\System\\": "src/System/", - "Project\\": "projects/", - "Plugins\\": "plugins/" + "Project\\": "projects/" }, "files": [ - "src/Kernel/Support/helpers.php", - "plugins/I18n/Support/helpers.php", - "plugins/Auth/Support/helpers.php", - "plugins/Authorization/Engine/functions.php", - "plugins/Cookie/Support/helpers.php", - "plugins/Pageflow/Support/helpers.php", - "plugins/Edge/Support/helpers.php" + "src/Kernel/Support/helpers.php" ], "exclude-from-classmap": [ "**/database/seeders/**", diff --git a/composer.lock b/composer.lock index 45a0007..9994437 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,931 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "58e21feb6f2ea0a8a95805afa209d27a", + "content-hash": "27129bf2894c149e35befd667d47df33", "packages": [ + { + "name": "alfacode-team/hkm-plugin-audit", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-audit", + "reference": "7eeb852da1114615d09677b44833c6137ee2c18f" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Audit\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'audit.trail'.", + "keywords": [ + "audit.trail", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-auth", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-auth", + "reference": "c96f1a5dadada1f28337e76421a6aaa5a97f014e" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Auth\\": "" + }, + "files": [ + "Support/helpers.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'auth.identity'.", + "keywords": [ + "auth.identity", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-authorization", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-authorization", + "reference": "132d4d9c79c82ae3e563fadc4f4ca30ee116bd21" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Authorization\\": "" + }, + "files": [ + "Engine/functions.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'authorization.policy'.", + "keywords": [ + "authorization.policy", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-commands", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-commands", + "reference": "0b2530c94e27dc9cecfbcf048796e76743b8e423" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Commands\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'system.commands'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "system.commands" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-cookie", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-cookie", + "reference": "2af167c24b5649cb464008b49d529b078a752967" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Cookie\\": "" + }, + "files": [ + "Support/helpers.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'http.cookies'.", + "keywords": [ + "hkm-kernel", + "http.cookies", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-crypto", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-crypto", + "reference": "931b54fa84907ab0dbefac0a522cf0b893941f41" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Crypto\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'crypto.services'.", + "keywords": [ + "crypto.services", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-database", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-database", + "reference": "53e295da788e7574e739def6e53f5da5e28b72c5" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Database\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'database.management'.", + "keywords": [ + "database.management", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-dev-tools", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-dev-tools", + "reference": "e85bd35e60f2557a4b57d57e40e103c43358defc" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\DevTools\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'dev.tooling'.", + "keywords": [ + "dev.tooling", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-edge", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-edge", + "reference": "b45e76353d0a02f324cad89d6cf7037b2069ccea" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Edge\\": "" + }, + "files": [ + "Support/helpers.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'edge.routing'.", + "keywords": [ + "edge.routing", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-feedback", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-feedback", + "reference": "7409e2cc94d8b6276eed6eda357d9de733a7d002" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Feedback\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'feedback.management'.", + "keywords": [ + "feedback.management", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-http-client", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-http-client", + "reference": "1a65b1341229b1e0c969dae2bd4e68ccd30bf5fc" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\HttpClient\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'http.client'.", + "keywords": [ + "hkm-kernel", + "http.client", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-i18n", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-i18n", + "reference": "b6709fcb121f7122e35839d44828be8395b1b60a" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\I18n\\": "" + }, + "files": [ + "Support/helpers.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'i18n.translation'.", + "keywords": [ + "hkm-kernel", + "i18n.translation", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-logger", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-logger", + "reference": "f4873fb7ee5d9cd5cd4aa5ddeff0d8b948a6dcd8" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Logger\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'logging.application'.", + "keywords": [ + "hkm-kernel", + "logging.application", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-mail", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-mail", + "reference": "b597576311ba602addc51a9d84d00188f290f5dc" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Mail\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'mail.delivery'.", + "keywords": [ + "hkm-kernel", + "mail.delivery", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-oauth2", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-oauth2", + "reference": "97a56e7dd60af2441e800c964c4253cafc216b2d" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\OAuth2\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'oauth.server'.", + "keywords": [ + "hkm-kernel", + "oauth.server", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-pageflow", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-pageflow", + "reference": "06b84f9d1f603308784ba235c96210606691ab1e" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Pageflow\\": "" + }, + "files": [ + "Support/helpers.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'http.pageflow'.", + "keywords": [ + "hkm-kernel", + "http.pageflow", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-redis-cache", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-redis-cache", + "reference": "a8e5f4b27a4ca84288dc29c958bcd3a633968265" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\RedisCache\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'cache.redis'.", + "keywords": [ + "cache.redis", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-security-filters", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-security-filters", + "reference": "30e9438ad3f63cd2bb239d490c3a4496b9860eb7" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\SecurityFilters\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'http.security_filters'.", + "keywords": [ + "hkm-kernel", + "http.security_filters", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-session", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-session", + "reference": "f5abb4eccabf18d7d095abee63a98d62e58d2607" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Session\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'session.management'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "session.management" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-settings", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-settings", + "reference": "5c6eaedc015ab83197eead794847afdd7d6de2b1" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Settings\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'tenant.settings'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "tenant.settings" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-siteseo", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-siteseo", + "reference": "a69b64e19cce9dc02a8d14c1539ea3479ea39afe" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\SiteSEO\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'seo.management'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "seo.management" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-social-auth", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-social-auth", + "reference": "836696686e46fdbebda3b5ddf9ba42a4a531325d" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\SocialAuth\\": "" + }, + "files": [ + "Socialite/Support/helpers.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'auth.social'.", + "keywords": [ + "auth.social", + "hkm-kernel", + "php", + "plugin" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-storage", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-storage", + "reference": "3574df004eedb651a6a32f16cf697aadb13f2cbd" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Storage\\": "" + }, + "files": [ + "Support/helpers.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'storage.local'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "storage.local" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-tenancy", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-tenancy", + "reference": "15e218a3007b54589470f72d3b97016123ebc766" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Tenancy\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'tenancy.routing'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "tenancy.routing" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-user", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-user", + "reference": "a4641d4016e7933fd9924e6d0fc660e22249e426" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\User\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'user.management'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "user.management" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-validation", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-validation", + "reference": "d8c97aa279cd698e9b7268c0af0bc4ddfa7f2bd4" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\Validation\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'validation.rules'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "validation.rules" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-view", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-view", + "reference": "1c241d9975807618a37cdff42a96be8d25e90eb4" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\View\\": "" + } + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'view.rendering'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "view.rendering" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, + { + "name": "alfacode-team/hkm-plugin-vitemanifest", + "version": "dev-main", + "dist": { + "type": "path", + "url": "../plugins/hkm-plugin-vitemanifest", + "reference": "007d79473f2a21d3340cda654ee58b1e9f82f58e" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Plugins\\ViteManifest\\": "" + }, + "files": [ + "Support/helpers.php" + ] + }, + "license": [ + "MIT" + ], + "description": "HKM Kernel plugin providing 'vite.manifest'.", + "keywords": [ + "hkm-kernel", + "php", + "plugin", + "vite.manifest" + ], + "transport-options": { + "symlink": true, + "relative": true + } + }, { "name": "alfacode-team/http", "version": "dev-master", @@ -87,7 +1010,7 @@ }, { "name": "alfacode-team/let-migrate", - "version": "dev-dev", + "version": "dev-68f72dbeb9740a814e7c47c937c955b3f130edda", "dist": { "type": "path", "url": "modules/let-migrate", @@ -183,7 +1106,7 @@ }, { "name": "alfacode-team/php-io-cli", - "version": "dev-dev", + "version": "dev-8a785060118e72b04e2afa653ecbb6f1c62d6512", "dist": { "type": "path", "url": "modules/php-io-cli", @@ -361,16 +1284,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.388.11", + "version": "3.390.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "ee6462591ad92c79635fb453732a34f245787e0f" + "reference": "c56ba4aafe1e8e6d80610c4e6506bbd655994bd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ee6462591ad92c79635fb453732a34f245787e0f", - "reference": "ee6462591ad92c79635fb453732a34f245787e0f", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/c56ba4aafe1e8e6d80610c4e6506bbd655994bd7", + "reference": "c56ba4aafe1e8e6d80610c4e6506bbd655994bd7", "shasum": "" }, "require": { @@ -378,9 +1301,9 @@ "ext-json": "*", "ext-pcre": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^7.4.5", - "guzzlehttp/promises": "^2.0", - "guzzlehttp/psr7": "^2.4.5", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", "mtdowling/jmespath.php": "^2.9.1", "php": ">=8.1", "psr/http-message": "^1.0 || ^2.0", @@ -452,9 +1375,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.388.11" + "source": "https://github.com/aws/aws-sdk-php/tree/3.390.5" }, - "time": "2026-07-21T18:08:14+00:00" + "time": "2026-08-05T18:11:23+00:00" }, { "name": "composer/pcre", @@ -600,21 +1523,21 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.15.1", + "version": "7.15.3", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f" + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", - "reference": "61443dfb33c62f308ee8add20f45b4d6e4bf8d2f", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/promises": "^2.5.2", "guzzlehttp/psr7": "^2.13", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", @@ -708,7 +1631,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.15.1" + "source": "https://github.com/guzzle/guzzle/tree/7.15.3" }, "funding": [ { @@ -724,20 +1647,20 @@ "type": "tidelift" } ], - "time": "2026-07-18T11:23:11+00:00" + "time": "2026-08-05T19:48:21+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.5.1", + "version": "2.5.2", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", - "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", "shasum": "" }, "require": { @@ -792,7 +1715,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.5.1" + "source": "https://github.com/guzzle/promises/tree/2.5.2" }, "funding": [ { @@ -808,7 +1731,7 @@ "type": "tidelift" } ], - "time": "2026-07-08T15:48:39+00:00" + "time": "2026-08-05T19:30:54+00:00" }, { "name": "guzzlehttp/psr7", @@ -1707,12 +2630,12 @@ "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "2de2366b98a3669fe6155adad65fea3e477290fe" + "reference": "77aaed12441eff519d7028cb851f893d7969f768" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/2de2366b98a3669fe6155adad65fea3e477290fe", - "reference": "2de2366b98a3669fe6155adad65fea3e477290fe", + "url": "https://api.github.com/repos/symfony/cache/zipball/77aaed12441eff519d7028cb851f893d7969f768", + "reference": "77aaed12441eff519d7028cb851f893d7969f768", "shasum": "" }, "require": { @@ -1798,7 +2721,7 @@ "type": "tidelift" } ], - "time": "2026-07-09T09:37:07+00:00" + "time": "2026-08-01T16:22:31+00:00" }, { "name": "symfony/cache-contracts", @@ -1882,16 +2805,16 @@ }, { "name": "symfony/console", - "version": "v8.1.1", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d" + "reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", - "reference": "b711a8ab808b6c074c6b8caef70d0fd8d6b6d07d", + "url": "https://api.github.com/repos/symfony/console/zipball/535e18a1b8925f6c01a55b171d157ab66c2ace15", + "reference": "535e18a1b8925f6c01a55b171d157ab66c2ace15", "shasum": "" }, "require": { @@ -1958,7 +2881,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.1.1" + "source": "https://github.com/symfony/console/tree/v8.1.2" }, "funding": [ { @@ -1978,7 +2901,7 @@ "type": "tidelift" } ], - "time": "2026-06-16T12:55:20+00:00" + "time": "2026-07-27T13:58:19+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2121,16 +3044,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2" + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/99aec13b82b4967ec5088222c4a3ecca955949c2", - "reference": "99aec13b82b4967ec5088222c4a3ecca955949c2", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217", + "reference": "17856b7a222664a26a5ea1cb06ee0721c2438217", "shasum": "" }, "require": { @@ -2168,7 +3091,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.1.0" + "source": "https://github.com/symfony/filesystem/tree/v8.1.2" }, "funding": [ { @@ -2188,20 +3111,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "symfony/http-foundation", - "version": "v8.1.1", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6a168c8fcee806b57ac020244da14293d1f9a883" + "reference": "9943adbf5a64e2951a8d9eb0485310d55624f0e8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6a168c8fcee806b57ac020244da14293d1f9a883", - "reference": "6a168c8fcee806b57ac020244da14293d1f9a883", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9943adbf5a64e2951a8d9eb0485310d55624f0e8", + "reference": "9943adbf5a64e2951a8d9eb0485310d55624f0e8", "shasum": "" }, "require": { @@ -2249,7 +3172,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v8.1.1" + "source": "https://github.com/symfony/http-foundation/tree/v8.1.2" }, "funding": [ { @@ -2269,20 +3192,20 @@ "type": "tidelift" } ], - "time": "2026-06-12T08:43:41+00:00" + "time": "2026-07-29T07:22:54+00:00" }, { "name": "symfony/mime", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664" + "reference": "75f4779d4ec2e13f24a3a7e5d0347c340c7ca627" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b164ae7e3f7915aacfe9ee155f2f358502440664", - "reference": "b164ae7e3f7915aacfe9ee155f2f358502440664", + "url": "https://api.github.com/repos/symfony/mime/zipball/75f4779d4ec2e13f24a3a7e5d0347c340c7ca627", + "reference": "75f4779d4ec2e13f24a3a7e5d0347c340c7ca627", "shasum": "" }, "require": { @@ -2335,7 +3258,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v8.1.0" + "source": "https://github.com/symfony/mime/tree/v8.1.2" }, "funding": [ { @@ -2355,7 +3278,7 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-29T08:00:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2529,16 +3452,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", - "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", "shasum": "" }, "require": { @@ -2587,7 +3510,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" }, "funding": [ { @@ -2607,7 +3530,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T05:58:03+00:00" + "time": "2026-07-28T08:25:59+00:00" }, { "name": "symfony/polyfill-intl-idn", @@ -2952,16 +3875,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.38.1", + "version": "v1.41.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", - "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", "shasum": "" }, "require": { @@ -3008,7 +3931,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" }, "funding": [ { @@ -3028,7 +3951,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T02:25:22+00:00" + "time": "2026-07-01T12:47:55+00:00" }, { "name": "symfony/service-contracts", @@ -3119,16 +4042,16 @@ }, { "name": "symfony/string", - "version": "v8.1.0", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", - "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", "shasum": "" }, "require": { @@ -3185,7 +4108,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.0" + "source": "https://github.com/symfony/string/tree/v8.1.2" }, "funding": [ { @@ -3205,20 +4128,20 @@ "type": "tidelift" } ], - "time": "2026-05-29T05:06:50+00:00" + "time": "2026-07-28T07:35:25+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.1.1", + "version": "v8.1.3", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "75b74315b4e4be40e5534cf9c5cc30dd0907ed71" + "reference": "766dac532a04d8980b4d83183fd3d1ea284bac11" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/75b74315b4e4be40e5534cf9c5cc30dd0907ed71", - "reference": "75b74315b4e4be40e5534cf9c5cc30dd0907ed71", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/766dac532a04d8980b4d83183fd3d1ea284bac11", + "reference": "766dac532a04d8980b4d83183fd3d1ea284bac11", "shasum": "" }, "require": { @@ -3268,7 +4191,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.1.1" + "source": "https://github.com/symfony/var-exporter/tree/v8.1.3" }, "funding": [ { @@ -3288,7 +4211,7 @@ "type": "tidelift" } ], - "time": "2026-06-27T09:05:56+00:00" + "time": "2026-07-29T16:43:23+00:00" } ], "packages-dev": [ @@ -4136,16 +5059,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.15", + "version": "v3.95.18", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "3e47e5d50046f87e3244acde2fe655d1a3b72555" + "reference": "a8b4e4216faabf67f4e96110ee99a48c96e4e683" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3e47e5d50046f87e3244acde2fe655d1a3b72555", - "reference": "3e47e5d50046f87e3244acde2fe655d1a3b72555", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a8b4e4216faabf67f4e96110ee99a48c96e4e683", + "reference": "a8b4e4216faabf67f4e96110ee99a48c96e4e683", "shasum": "" }, "require": { @@ -4185,10 +5108,10 @@ "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31 || ^13.0.6", "symfony/polyfill-php85": "^1.38", - "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", - "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.1", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.1" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -4229,7 +5152,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.15" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.18" }, "funding": [ { @@ -4237,7 +5160,7 @@ "type": "github" } ], - "time": "2026-07-15T09:51:47+00:00" + "time": "2026-07-30T15:46:02+00:00" }, { "name": "justinrainbow/json-schema", @@ -4627,8 +5550,8 @@ "version": "2.2.x-dev", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/905d6cdf581b0ad307924aa8e4ed95772864fe93", - "reference": "905d6cdf581b0ad307924aa8e4ed95772864fe93", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c2dbd451df3156b77c2341449d2a0478a685bd4e", + "reference": "c2dbd451df3156b77c2341449d2a0478a685bd4e", "shasum": "" }, "require": { @@ -4685,20 +5608,20 @@ "type": "github" } ], - "time": "2026-07-22T08:07:53+00:00" + "time": "2026-08-05T08:55:55+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "14.2.3", + "version": "14.2.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "82f6e49ff224e2cde923d74425e583a883910783" + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783", - "reference": "82f6e49ff224e2cde923d74425e583a883910783", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/048a5c12bdb4580f4767ce2761793a16b170fbe4", + "reference": "048a5c12bdb4580f4767ce2761793a16b170fbe4", "shasum": "" }, "require": { @@ -4755,7 +5678,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.4" }, "funding": [ { @@ -4775,7 +5698,7 @@ "type": "tidelift" } ], - "time": "2026-07-06T15:04:02+00:00" + "time": "2026-07-30T17:01:07+00:00" }, { "name": "phpunit/php-file-iterator", @@ -5076,12 +5999,12 @@ "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "6baab93983ce97e1f84834037d12913228d40647" + "reference": "c7ffde6e9a19fe37466aa3ddfc3755cc7a432796" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6baab93983ce97e1f84834037d12913228d40647", - "reference": "6baab93983ce97e1f84834037d12913228d40647", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c7ffde6e9a19fe37466aa3ddfc3755cc7a432796", + "reference": "c7ffde6e9a19fe37466aa3ddfc3755cc7a432796", "shasum": "" }, "require": { @@ -5095,12 +6018,12 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.2.3", + "phpunit/php-code-coverage": "^14.2.4", "phpunit/php-file-iterator": "^7.0.0", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.0", "phpunit/php-timer": "^9.0.0", - "sebastian/cli-parser": "^5.0.0", + "sebastian/cli-parser": "^5.0.1", "sebastian/comparator": "^8.3.0", "sebastian/diff": "^9.0", "sebastian/environment": "^9.3.2", @@ -5109,7 +6032,7 @@ "sebastian/git-state": "^1.0", "sebastian/global-state": "^9.0.1", "sebastian/object-enumerator": "^8.0.0", - "sebastian/recursion-context": "^8.0.0", + "sebastian/recursion-context": "^8.0.1", "sebastian/type": "^7.0.1", "sebastian/version": "^7.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -5160,7 +6083,7 @@ "type": "other" } ], - "time": "2026-07-19T22:03:11+00:00" + "time": "2026-08-05T05:00:29+00:00" }, { "name": "psr/event-dispatcher", @@ -5740,23 +6663,23 @@ }, { "name": "sebastian/cli-parser", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/eeb759ad3146b7096fb59c3195d39e071cd409e3", + "reference": "eeb759ad3146b7096fb59c3195d39e071cd409e3", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { @@ -5785,7 +6708,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/cli-parser/issues", "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.1" }, "funding": [ { @@ -5805,7 +6728,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:39:44+00:00" + "time": "2026-08-01T04:27:14+00:00" }, { "name": "sebastian/comparator", @@ -6636,23 +7559,23 @@ }, { "name": "sebastian/recursion-context", - "version": "8.0.0", + "version": "8.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", + "reference": "32dba72f2b4642d6a93db22d6c0a9280ff2e3ca0", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "phpunit/phpunit": "^13.2.6" }, "type": "library", "extra": { @@ -6688,7 +7611,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.1" }, "funding": [ { @@ -6708,7 +7631,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T04:51:28+00:00" + "time": "2026-08-03T05:58:12+00:00" }, { "name": "sebastian/type", @@ -6911,16 +7834,16 @@ }, { "name": "seld/phar-utils", - "version": "1.2.1", + "version": "1.2.2", "source": { "type": "git", "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + "reference": "990bbd0e92caa216d52eca0935f6e35e589bfaa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", - "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/990bbd0e92caa216d52eca0935f6e35e589bfaa5", + "reference": "990bbd0e92caa216d52eca0935f6e35e589bfaa5", "shasum": "" }, "require": { @@ -6953,9 +7876,9 @@ ], "support": { "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.2" }, - "time": "2022-08-31T10:31:18+00:00" + "time": "2026-08-01T12:48:55+00:00" }, { "name": "seld/signal-handler", @@ -7072,16 +7995,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.1.1", + "version": "v8.1.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0" + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/abd6c11dc468725d1627302ad10f6cd486e9e3d0", - "reference": "abd6c11dc468725d1627302ad10f6cd486e9e3d0", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", + "reference": "c14c05a9e6da7f5e375e6efc28952c7e7dbddffb", "shasum": "" }, "require": { @@ -7134,7 +8057,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.1" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.2" }, "funding": [ { @@ -7154,7 +8077,7 @@ "type": "tidelift" } ], - "time": "2026-06-09T12:28:30+00:00" + "time": "2026-07-22T15:42:13+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7748,16 +8671,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.14", + "version": "v7.4.15", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358" + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", - "reference": "9a3a56a4a1e65a5cb4f8d13801fe8ab0a170e358", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", "shasum": "" }, "require": { @@ -7773,7 +8696,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/process": "^6.4|^7.0|^8.0", "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "twig/twig": "^3.12|^4.0" }, "bin": [ "Resources/bin/var-dump-server" @@ -7811,7 +8734,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.14" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.15" }, "funding": [ { @@ -7831,7 +8754,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-07-21T15:13:06+00:00" }, { "name": "theseer/tokenizer", @@ -7887,6 +8810,34 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { + "alfacode-team/hkm-plugin-audit": 20, + "alfacode-team/hkm-plugin-auth": 20, + "alfacode-team/hkm-plugin-authorization": 20, + "alfacode-team/hkm-plugin-commands": 20, + "alfacode-team/hkm-plugin-cookie": 20, + "alfacode-team/hkm-plugin-crypto": 20, + "alfacode-team/hkm-plugin-database": 20, + "alfacode-team/hkm-plugin-dev-tools": 20, + "alfacode-team/hkm-plugin-edge": 20, + "alfacode-team/hkm-plugin-feedback": 20, + "alfacode-team/hkm-plugin-http-client": 20, + "alfacode-team/hkm-plugin-i18n": 20, + "alfacode-team/hkm-plugin-logger": 20, + "alfacode-team/hkm-plugin-mail": 20, + "alfacode-team/hkm-plugin-oauth2": 20, + "alfacode-team/hkm-plugin-pageflow": 20, + "alfacode-team/hkm-plugin-redis-cache": 20, + "alfacode-team/hkm-plugin-security-filters": 20, + "alfacode-team/hkm-plugin-session": 20, + "alfacode-team/hkm-plugin-settings": 20, + "alfacode-team/hkm-plugin-siteseo": 20, + "alfacode-team/hkm-plugin-social-auth": 20, + "alfacode-team/hkm-plugin-storage": 20, + "alfacode-team/hkm-plugin-tenancy": 20, + "alfacode-team/hkm-plugin-user": 20, + "alfacode-team/hkm-plugin-validation": 20, + "alfacode-team/hkm-plugin-view": 20, + "alfacode-team/hkm-plugin-vitemanifest": 20, "phpstan/phpstan": 20, "phpunit/phpunit": 20, "psr/http-message": 20, diff --git a/plugins/Audit/API/Contracts/AuditReaderContract.php b/plugins/Audit/API/Contracts/AuditReaderContract.php deleted file mode 100644 index ed375dd..0000000 --- a/plugins/Audit/API/Contracts/AuditReaderContract.php +++ /dev/null @@ -1,44 +0,0 @@ - Newest first across the whole trail. */ - public function recent(int $limit = 50, ?int $beforeId = null): array; - - /** @return list Newest first for one tenant. */ - public function forTenant(string $tenantId, int $limit = 50, ?int $beforeId = null): array; - - /** @return list Newest first for one user. */ - public function forUser(string $userId, int $limit = 50, ?int $beforeId = null): array; - - /** @return list Newest first for one action (e.g. 'tenant.switch'). */ - public function byAction(string $action, int $limit = 50, ?int $beforeId = null): array; - - /** A single entry by its public event id, or null. */ - public function find(string $eventId): ?AuditEntry; - - /** How many entries a tenant has accrued. */ - public function countForTenant(string $tenantId): int; - - /** - * Delete entries strictly older than the cutoff (retention / GDPR purge). - * - * @return int rows removed - */ - public function purgeOlderThan(\DateTimeImmutable $cutoff): int; -} diff --git a/plugins/Audit/API/Contracts/AuditServiceContract.php b/plugins/Audit/API/Contracts/AuditServiceContract.php deleted file mode 100644 index 1ab6cd9..0000000 --- a/plugins/Audit/API/Contracts/AuditServiceContract.php +++ /dev/null @@ -1,34 +0,0 @@ - $meta structured, non-PII context - */ - public function record( - string $action, - ?string $userId = null, - ?string $tenantId = null, - array $meta = [], - ?string $ip = null, - ): void; -} diff --git a/plugins/Audit/Application/Ports/AuditWriter.php b/plugins/Audit/Application/Ports/AuditWriter.php deleted file mode 100644 index 47a6fa5..0000000 --- a/plugins/Audit/Application/Ports/AuditWriter.php +++ /dev/null @@ -1,27 +0,0 @@ - $meta - */ - public function write( - string $action, - ?string $userId = null, - ?string $tenantId = null, - array $meta = [], - ?string $ip = null, - ): void; -} diff --git a/plugins/Audit/Application/Services/AuditService.php b/plugins/Audit/Application/Services/AuditService.php deleted file mode 100644 index a6034f2..0000000 --- a/plugins/Audit/Application/Services/AuditService.php +++ /dev/null @@ -1,84 +0,0 @@ -sink = $sink ?? static fn (string $line) => error_log($line); - } - - public function record( - string $action, - ?string $userId = null, - ?string $tenantId = null, - array $meta = [], - ?string $ip = null, - ): void { - $userId ??= ($this->actorId ?: null); - $tenantId ??= ($this->currentTenant !== null && $this->currentTenant !== '' ? $this->currentTenant : null); - $ip ??= ($this->clientIp !== null && $this->clientIp !== '' ? $this->clientIp : null); - - $line = json_encode([ - 'source' => 'audit', - 'action' => $action, - 'user' => $userId, - 'tenant' => $tenantId, - 'ip' => $ip, - 'meta' => $meta, - 'timestamp' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339), - ], JSON_UNESCAPED_SLASHES); - - if ($line !== false) { - ($this->sink)($line); - } - - if ($this->writer === null) { - return; - } - - try { - $this->writer->write($action, $userId, $tenantId, $meta, $ip); - } catch (\Throwable) { - // Best-effort — the log line above is the durable fallback. - } - } -} diff --git a/plugins/Audit/Domain/Entities/AuditEntry.php b/plugins/Audit/Domain/Entities/AuditEntry.php deleted file mode 100644 index f4298f0..0000000 --- a/plugins/Audit/Domain/Entities/AuditEntry.php +++ /dev/null @@ -1,57 +0,0 @@ - $row */ - public static function fromRow(array $row): self - { - $metaRaw = $row['meta'] ?? null; - $meta = is_string($metaRaw) && $metaRaw !== '' - ? (json_decode($metaRaw, true) ?: []) - : []; - - $e = (new self())->forceFill([ - 'id' => (int) $row['id'], - 'eventId' => (string) $row['event_id'], - 'userId' => isset($row['user_id']) ? (string) $row['user_id'] : null, - 'tenantId' => isset($row['tenant_id']) ? (string) $row['tenant_id'] : null, - 'action' => (string) $row['action'], - 'ip' => isset($row['ip']) ? (string) $row['ip'] : null, - 'meta' => is_array($meta) ? $meta : [], - 'occurredAt' => (string) $row['occurred_at'], - ]); - $e->syncOriginal(); - - return $e; - } - - /** @return array */ - public function toArray(bool $onlyChanged = false): array - { - return [ - 'id' => $this->id, - 'event_id' => $this->eventId, - 'user_id' => $this->userId, - 'tenant_id' => $this->tenantId, - 'action' => $this->action, - 'ip' => $this->ip, - 'meta' => $this->meta, - 'occurred_at' => $this->occurredAt, - ]; - } -} diff --git a/plugins/Audit/Infrastructure/Persistence/AuditLogRepository.php b/plugins/Audit/Infrastructure/Persistence/AuditLogRepository.php deleted file mode 100644 index cac6e86..0000000 --- a/plugins/Audit/Infrastructure/Persistence/AuditLogRepository.php +++ /dev/null @@ -1,123 +0,0 @@ -page('', [], $limit, $beforeId); - } - - public function forTenant(string $tenantId, int $limit = 50, ?int $beforeId = null): array - { - return $this->page('tenant_id = :tenant_id', ['tenant_id' => $tenantId], $limit, $beforeId); - } - - public function forUser(string $userId, int $limit = 50, ?int $beforeId = null): array - { - return $this->page('user_id = :user_id', ['user_id' => $userId], $limit, $beforeId); - } - - public function byAction(string $action, int $limit = 50, ?int $beforeId = null): array - { - return $this->page('action = :action', ['action' => $action], $limit, $beforeId); - } - - public function find(string $eventId): ?AuditEntry - { - try { - $row = $this->central->queryOne( - self::SELECT . ' WHERE event_id = :event_id LIMIT 1', - ['event_id' => $eventId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to load audit entry.', layer: 'repository.audit', previous: $e); - } - - return $row === null ? null : AuditEntry::fromRow($row); - } - - public function countForTenant(string $tenantId): int - { - try { - $row = $this->central->queryOne( - 'SELECT COUNT(*) AS c FROM audit_log WHERE tenant_id = :tenant_id', - ['tenant_id' => $tenantId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to count audit entries.', layer: 'repository.audit', previous: $e); - } - - return (int) ($row['c'] ?? 0); - } - - public function purgeOlderThan(\DateTimeImmutable $cutoff): int - { - try { - return $this->central->execute( - 'DELETE FROM audit_log WHERE occurred_at < :cutoff', - ['cutoff' => $cutoff->format('Y-m-d H:i:s')], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to purge audit entries.', layer: 'repository.audit', previous: $e); - } - } - - /** - * Run a keyset-paginated listing with an optional WHERE filter. - * - * @param array $params - * @return list - */ - private function page(string $where, array $params, int $limit, ?int $beforeId): array - { - $limit = max(1, min(self::MAX_LIMIT, $limit)); - $clauses = $where !== '' ? [$where] : []; - - if ($beforeId !== null) { - $clauses[] = 'id < ' . (int) $beforeId; // validated int — keyset cursor - } - - $sql = self::SELECT - . ($clauses !== [] ? ' WHERE ' . implode(' AND ', $clauses) : '') - . ' ORDER BY id DESC LIMIT ' . $limit; - - try { - $rows = $this->central->query($sql, $params); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to list audit entries.', layer: 'repository.audit', previous: $e); - } - - return array_map(static fn (array $r): AuditEntry => AuditEntry::fromRow($r), $rows); - } -} diff --git a/plugins/Audit/Infrastructure/Persistence/AuditTrail.php b/plugins/Audit/Infrastructure/Persistence/AuditTrail.php deleted file mode 100644 index dc3a2f1..0000000 --- a/plugins/Audit/Infrastructure/Persistence/AuditTrail.php +++ /dev/null @@ -1,65 +0,0 @@ -central->execute( - 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) - VALUES (:eid, :uid, :tid, :action, :ip, :meta, :ts)', - [ - 'eid' => self::eventId(), - 'uid' => $userId, - 'tid' => $tenantId, - 'action' => $action, - 'ip' => $ip, - 'meta' => $meta === [] ? null : json_encode($meta, JSON_UNESCAPED_SLASHES), - 'ts' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to write audit entry.', - layer: 'repository.audit', - context: ['action' => $action], - previous: $e, - ); - } - } - - /** Unique event id for the row (fits the char(31) column). */ - private static function eventId(): string - { - return bin2hex(random_bytes(15)); // 30 hex chars - } -} diff --git a/plugins/Audit/Provider.php b/plugins/Audit/Provider.php deleted file mode 100644 index 5908a69..0000000 --- a/plugins/Audit/Provider.php +++ /dev/null @@ -1,94 +0,0 @@ - */ - public function requires(): array - { - return ['database.management']; - } - - /** @return list */ - public function exposes(): array - { - return [AuditServiceContract::class, AuditReaderContract::class]; - } - - public function register(ModuleContainer $container): void - { - // Write side: persistence seam behind the audit service (central conn). - $container->bindInternal(AuditWriter::class, static fn (ModuleContainer $c): AuditWriter => - new AuditTrail($c->make(DatabasePort::class))); - - // Published write contract — the ONE way any plugin records an action. - // Auto-fills actor (Identity) and tenant (Tenancy's `tenant.current` - // container key — a plain string, no Tenancy import) when omitted. - $container->bind(AuditServiceContract::class, static function (ModuleContainer $c): AuditServiceContract { - $identity = $c->has(Identity::class) ? $c->make(Identity::class) : null; - $actorId = $identity !== null ? ($identity->userId ?: null) : null; - - // Tenant source: the routed tenant (`tenant.current`, set by Tenancy's - // TenantContextStage) when present, else the authoritative Identity - // tenant — which is always bound at load and drives the routing itself, - // so it is populated even on paths where the stage bound nothing. - $tenantId = $c->has('tenant.current') - ? ((string) $c->make('tenant.current') ?: null) - : ($identity !== null ? ($identity->tenantId ?: null) : null); - - $clientIp = $c->has('client.ip') ? (string) $c->make('client.ip') : null; - - return new AuditService( - writer: $c->make(AuditWriter::class), - actorId: $actorId, - currentTenant: $tenantId, - clientIp: $clientIp, - ); - }); - - // Published read/query contract for control-plane admin surfaces. - $container->bind(AuditReaderContract::class, static fn (ModuleContainer $c): AuditReaderContract => - new AuditLogRepository($c->make(DatabasePort::class))); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // No pipeline hooks or subscriptions — a pure infrastructure domain. - } - - -} diff --git a/plugins/Audit/database/migrations/.gitkeep b/plugins/Audit/database/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/Audit/database/tenant-template/2026_06_22_000005_create_audit_log_table.php b/plugins/Audit/database/tenant-template/2026_06_22_000005_create_audit_log_table.php deleted file mode 100644 index 0f44421..0000000 --- a/plugins/Audit/database/tenant-template/2026_06_22_000005_create_audit_log_table.php +++ /dev/null @@ -1,47 +0,0 @@ -create('audit_log', static function ($t) { - $t->id(); - $t->char('event_id', 31); - $t->char('user_id', 31)->nullable(); - $t->char('tenant_id', 31)->nullable(); - $t->string('action', 64)->comment('login|tenant.switch|tenant.create|member.invite|...'); - $t->string('ip', 45)->nullable(); - $t->json('meta')->nullable(); - $t->timestamp('occurred_at')->default('CURRENT_TIMESTAMP'); - - $t->unique(['event_id'], 'uniq_event_id'); - $t->index(['tenant_id', 'occurred_at'], 'idx_tenant_time'); - $t->index(['user_id', 'occurred_at'], 'idx_user_time'); - $t->index(['action', 'occurred_at'], 'idx_action_time'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('audit_log'); - } -}; diff --git a/plugins/Audit/module.json b/plugins/Audit/module.json deleted file mode 100644 index 602e71c..0000000 --- a/plugins/Audit/module.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "audit", - "version": "1.0.0", - "solves": "audit.trail", - "type": "module", - - "requires": ["database.management"], - "exposes": ["AuditServiceContract", "AuditReaderContract"], - - "routes": [], - "emits": [], - "listens": [], - - "documentation": "The Audit plugin — owns the audit.trail domain and the shared central `audit_log` table. It is the SINGLE writer/reader of that table: other plugins (User, Feedback, Tenancy) require `audit.trail` and record security-relevant actions through the published AuditServiceContract instead of writing the table themselves (which would duplicate the writer and violate table ownership). Records identifiers + structured meta only — never passwords, hashes, tokens, or raw PII — so the trail is safe to ship to a SIEM. Each entry is emitted as a JSON log line (source=audit) AND persisted best-effort to `audit_log` (an audit write never breaks the action it records). The service auto-fills actor (Identity) and tenant (TenantContextStage's `tenant.current`) when a caller does not pass them. AuditReaderContract exposes keyset-paginated queries + retention purge for control-plane admin surfaces. Enabling publishes database/ (the central audit_log migration).", - - "config": [] -} diff --git a/plugins/Auth/API/Contracts/AuthServiceContract.php b/plugins/Auth/API/Contracts/AuthServiceContract.php deleted file mode 100644 index 3fd3dd0..0000000 --- a/plugins/Auth/API/Contracts/AuthServiceContract.php +++ /dev/null @@ -1,123 +0,0 @@ -check()/id()/via()/hasScope()). - */ - public function guard(Request $request): Guard; - - /** - * List the personal access tokens issued to a user (newest first), without - * any secret material. Replaces the old HasApiTokens `tokens()` accessor. - * - * @return list - */ - public function tokensFor(string $userId): array; - - /** - * Issue a signed JWT for a user. - * - * Tenant context is carried by the `tnt` claim (multi-tenant control plane). - * Omit it (or pass '') for a login/unscoped token that routes to the central - * connection; set it ONLY after verifying membership in the central - * `user_tenants` table at tenant-selection time. `tenant` is accepted as a - * legacy alias. - * - * Display-identity claims (OIDC names) may be supplied: `preferred_username`, - * `email`, `name` (full name from the tenant user_profiles table). When - * username/email are omitted they are filled from the central user record; - * `name` is only minted by tenant-aware callers (tenant selection). - * - * @param array{roles?:list,permissions?:list,tnt?:string,tenant?:string,preferred_username?:string,email?:string,name?:string} $claims - */ - public function issueJwt(string $userId, array $claims = [], int $ttlSeconds = 3600): string; - - /** - * Create a personal access token. Returns the plaintext token ONCE; only a - * hash is persisted. - * - * @param list $abilities Scopes granted to the token (become Identity.permissions). - * @param int|null $ttlSeconds Absolute lifetime; null = non-expiring token. - * - * @return array{id:string,token:string} - */ - public function createPersonalAccessToken( - string $userId, - string $name = 'default', - array $abilities = [], - ?int $ttlSeconds = null, - ): array; - - /** - * Revoke a personal access token by its id. - */ - public function revokePersonalAccessToken(string $id): void; - - /** - * Establish a stateful web/AJAX login on the given session. - * - * Rotates the session id (fixation defence) and stores the identity so - * SessionAuthStage can rebuild an Identity on subsequent requests. Call AFTER - * verifying credentials (e.g. UserServiceContract::verifyCredentials). - * - * Display identity: username/email are filled from the central user record - * when omitted; $fullName (first + last from the tenant user_profiles table) - * is stored as supplied — pass it when tenant context is known. - * - * @param list $roles - * @param list $permissions - */ - public function startSession( - SessionPort $session, - string $userId, - array $roles = [], - array $permissions = [], - string $tenantId = '', - string $username = '', - string $email = '', - string $fullName = '', - ?string $avatarUrl = null, - ): void; - - /** Tear down a web/AJAX login: clear attributes and rotate the session id. */ - public function endSession(SessionPort $session): void; - - /** - * Revoke a JWT before its natural expiry by deny-listing its `jti` claim. - * No-op when no cache/revocation backend is configured. - * - * @param string $jti The token's unique id (the `jti` claim). - * @param int $ttlSeconds Keep the deny-list entry at least this long - * (set it to the token's remaining lifetime). - */ - public function revokeJwt(string $jti, int $ttlSeconds = 3600): void; - - /** - * Hash a plaintext password for storage (bcrypt/argon2 via HashingPort). - */ - public function hashPassword(string $plain): string; - - /** - * Verify a plaintext password against a stored hash (timing-safe). - */ - public function verifyPassword(string $plain, string $hash): bool; -} diff --git a/plugins/Auth/API/Contracts/RefreshTokenServiceContract.php b/plugins/Auth/API/Contracts/RefreshTokenServiceContract.php deleted file mode 100644 index b42818b..0000000 --- a/plugins/Auth/API/Contracts/RefreshTokenServiceContract.php +++ /dev/null @@ -1,41 +0,0 @@ -." — shown once - public TokenDTO $token, // the persisted record (no secret) - ) {} - - /** The plaintext token — send it to the client now; it is unrecoverable later. */ - public function plainTextToken(): string - { - return $this->accessToken; - } - - public function id(): string - { - return $this->token->id; - } - - /** @return array{token:string,id:string,abilities:list} */ - public function toArray(): array - { - return [ - 'token' => $this->accessToken, - 'id' => $this->token->id, - 'abilities' => $this->token->abilities, - ]; - } -} diff --git a/plugins/Auth/API/DTOs/RefreshRotation.php b/plugins/Auth/API/DTOs/RefreshRotation.php deleted file mode 100644 index 922cad7..0000000 --- a/plugins/Auth/API/DTOs/RefreshRotation.php +++ /dev/null @@ -1,38 +0,0 @@ - */ - public function toArray(): array - { - return [ - 'accessToken' => $this->accessToken, - 'tokenType' => 'Bearer', - 'expiresIn' => $this->expiresIn, - 'refreshToken' => $this->refreshToken, - 'refreshExpiresAt' => $this->refreshExpiresAt, - 'tenantId' => $this->tenantId, - 'role' => $this->role, - ]; - } -} diff --git a/plugins/Auth/API/DTOs/RefreshTokenIssued.php b/plugins/Auth/API/DTOs/RefreshTokenIssued.php deleted file mode 100644 index 29ba406..0000000 --- a/plugins/Auth/API/DTOs/RefreshTokenIssued.php +++ /dev/null @@ -1,24 +0,0 @@ - */ - public function toArray(): array - { - return ['tokenId' => $this->tokenId, 'token' => $this->token, 'expiresAt' => $this->expiresAt]; - } -} diff --git a/plugins/Auth/API/DTOs/TokenDTO.php b/plugins/Auth/API/DTOs/TokenDTO.php deleted file mode 100644 index 9811a11..0000000 --- a/plugins/Auth/API/DTOs/TokenDTO.php +++ /dev/null @@ -1,91 +0,0 @@ - $abilities - */ - public function __construct( - public string $id, - public string $name, - public array $abilities, - public ?\DateTimeImmutable $expiresAt, - public ?\DateTimeImmutable $lastUsedAt, - public ?\DateTimeImmutable $createdAt, - ) {} - - /** @param array $row */ - public static function fromRow(array $row): self - { - $abilities = $row['abilities'] ?? []; - if (is_string($abilities)) { - $decoded = json_decode($abilities, true); - $abilities = is_array($decoded) ? $decoded : []; - } - - return new self( - id: (string) ($row['id'] ?? ''), - name: (string) ($row['name'] ?? 'default'), - abilities: array_values(array_filter((array) $abilities, 'is_string')), - expiresAt: self::date($row['expires_at'] ?? null), - lastUsedAt: self::date($row['last_used_at'] ?? null), - createdAt: self::date($row['created_at'] ?? null), - ); - } - - /** - * True when the token carries the given ability — hierarchical, so `admin` - * satisfies `admin:write`, and `*` grants everything. - */ - public function can(string $ability): bool - { - return \Plugins\Auth\API\ScopeInheritance::satisfies($this->abilities, $ability); - } - - public function isExpired(?\DateTimeImmutable $now = null): bool - { - return $this->expiresAt !== null - && $this->expiresAt <= ($now ?? new \DateTimeImmutable()); - } - - /** @return array */ - public function toArray(): array - { - return [ - 'id' => $this->id, - 'name' => $this->name, - 'abilities' => $this->abilities, - 'expires_at' => $this->expiresAt?->format(\DateTimeInterface::RFC3339), - 'last_used_at' => $this->lastUsedAt?->format(\DateTimeInterface::RFC3339), - 'created_at' => $this->createdAt?->format(\DateTimeInterface::RFC3339), - ]; - } - - private static function date(mixed $value): ?\DateTimeImmutable - { - if ($value instanceof \DateTimeImmutable) { - return $value; - } - if (is_string($value) && $value !== '') { - try { - return new \DateTimeImmutable($value); - } catch (\Exception) { - return null; - } - } - - return null; - } -} diff --git a/plugins/Auth/API/Guard.php b/plugins/Auth/API/Guard.php deleted file mode 100644 index 0fafd2c..0000000 --- a/plugins/Auth/API/Guard.php +++ /dev/null @@ -1,135 +0,0 @@ - PersonalAccessTokenLayer -> - * SessionAuthStage) IS the driver chain, and it has already resolved WHO the - * caller is and by WHICH credential type by the time a controller runs. This - * object just reads that verdict off the immutable Request. - * - * Old ergonomics -> new surface: - * Auth::check() -> $guard->check() - * Auth::user() -> $guard->user() (the Identity) - * Auth::id() -> $guard->id() - * Auth::guard('jwt') -> $guard->via() === 'jwt' - * $token->can('scope') -> $guard->hasScope('scope') - * - * Build one with Guard::fromRequest($request) or the AuthServiceContract::guard() - * helper; controllers get it via the InteractsWithAuth concern. - */ -final readonly class Guard -{ - public function __construct(private Identity $identity) {} - - public static function fromRequest(Request $request): self - { - return new self($request->identity() ?? Identity::guest()); - } - - /** - * Test/setup helper — build a Guard for a synthetic user with the given - * scopes. GDA-native parity with the old OAuth::actingAs(): scopes become - * hierarchical permissions honoured by hasScope(). - * - * @param list $scopes - * @param list $roles - */ - public static function actingAs( - string $userId, - array $scopes = [], - array $roles = [], - string $tenantId = '', - string $tokenType = 'jwt', - ): self { - return new self(new Identity($userId, $tenantId, $roles, array_values($scopes), $tokenType)); - } - - /** True when a non-guest Identity authenticated this request. */ - public function check(): bool - { - return !$this->identity->isGuest(); - } - - /** True when nobody authenticated (public/anonymous request). */ - public function guest(): bool - { - return $this->identity->isGuest(); - } - - /** The resolved Identity (guest Identity when unauthenticated). */ - public function user(): Identity - { - return $this->identity; - } - - /** The authenticated user id, or '' for a guest. */ - public function id(): string - { - return $this->identity->userId; - } - - /** The tenant this request is scoped to ('' = central/unscoped). */ - public function tenantId(): string - { - return $this->identity->tenantId; - } - - /** - * Which credential type authenticated the request: 'jwt' | 'api_key' | - * 'session' | 'none'. This is the "named guard" the old AuthManager exposed, - * but derived from the actual verdict rather than selected up front. - */ - public function via(): string - { - return $this->identity->tokenType; - } - - /** True when authenticated by a Bearer credential (JWT or PAT), not a session. */ - public function viaToken(): bool - { - return $this->identity->tokenType === 'jwt' - || $this->identity->tokenType === 'api_key'; - } - - /** True when authenticated by a stateful web/AJAX session. */ - public function viaSession(): bool - { - return $this->identity->tokenType === 'session'; - } - - public function hasRole(string $role): bool - { - return $this->identity->hasRole($role); - } - - public function hasPermission(string $permission): bool - { - return $this->identity->hasPermission($permission); - } - - /** - * Token scope check — the GDA equivalent of the old $token->can($scope). - * OAuth2 access-token scopes land in Identity.permissions namespaced as - * "scope:" (see OAuth2 TokenIssuer); first-party PAT abilities land - * as bare permissions. Accept either form. - * - * Scopes are HIERARCHICAL (colon-delimited): a held scope satisfies any of - * its descendants, so a token carrying `admin` passes a required - * `admin:users:write` (port of the old ResolvesInheritedScopes). `*` grants - * everything. - */ - public function hasScope(string $scope): bool - { - return ScopeInheritance::satisfies($this->identity->permissions, $scope); - } -} diff --git a/plugins/Auth/API/ScopeInheritance.php b/plugins/Auth/API/ScopeInheritance.php deleted file mode 100644 index b43387f..0000000 --- a/plugins/Auth/API/ScopeInheritance.php +++ /dev/null @@ -1,56 +0,0 @@ -` - * (OAuth2 delegated tokens) — both forms are understood. - */ -final class ScopeInheritance -{ - /** - * @param list $held the permissions/abilities the caller holds - * @param string $required the scope being checked - */ - public static function satisfies(array $held, string $required): bool - { - foreach ($held as $permission) { - if ($permission === '*') { - return true; - } - - $scope = str_starts_with($permission, 'scope:') ? substr($permission, 6) : $permission; - - // Exact match, or $scope is an ancestor of $required (admin ⊇ admin:write). - if ($scope === $required || str_starts_with($required, $scope . ':')) { - return true; - } - } - - return false; - } - - /** - * Expand a scope into itself plus all ancestors — 'a:b:c' → - * ['a', 'a:b', 'a:b:c']. Retained for parity with the old API. - * - * @return list - */ - public static function ancestors(string $scope): array - { - $parts = explode(':', $scope); - $out = []; - for ($i = 1, $n = count($parts); $i <= $n; $i++) { - $out[] = implode(':', array_slice($parts, 0, $i)); - } - - return $out; - } -} diff --git a/plugins/Auth/AUTH_GUIDE.pdf b/plugins/Auth/AUTH_GUIDE.pdf deleted file mode 100644 index 8f2bdcee63dfef6b87301ccfe458c9b215315e7d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 236622 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 %hTmwJs@O>^NhfK9b*m*- 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

cTi1qt?AJ3 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%0AukzNw%PATF9?kAR>iS%-d1&q?`9T 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

8eYJ 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 // default guard - * $manager->guard('api')->user(); - * $manager->user(); // default guard's user - * $manager->provider('users'); // a named UserProvider - * - * "Scan drivers": driver classes under Infrastructure/Auth/Drivers implementing - * GuardDriver are filesystem-scanned once per process and keyed by driverName(). - * (This is a deliberate, documented exception to the GDA "nothing auto-discovered - * at runtime" rule — the scan is boot-time and cached, never on the hot path.) - */ -final class AuthManager -{ - /** @var array>|null process-cached driver map */ - private static ?array $driverMap = null; - - /** @var array request-scoped guard cache */ - private array $guards = []; - - /** @var array request-scoped provider cache */ - private array $providers = []; - - /** Set by setRequest() before resolving guards (Request is not container-bound). */ - private ?Request $request = null; - - /** @var array custom guard creators */ - private array $customGuardCreators = []; - - /** @var array custom provider creators */ - private array $customProviderCreators = []; - - /** @var \Closure(?string):?Authenticatable|null shared user resolver override */ - private ?\Closure $userResolver = null; - - /** - * @param array $config auth_config() - * @param \Closure(string): ?UserProvider $providerFactory builds a named provider - * @param \Closure(string,UserProvider,Request): ?StatefulGuard $statefulFactory - * builds the WRITE-side guard (attempt/login/logout) for stateful - * drivers — wired by the Provider with the module's collaborators. - */ - public function __construct( - private readonly array $config, - private readonly \Closure $providerFactory, - private readonly ?SessionPort $session = null, - private readonly ?\Plugins\Auth\API\Contracts\AuthServiceContract $auth = null, - private readonly ?\Closure $statefulFactory = null, - private readonly ?\Plugins\Auth\API\Contracts\RefreshTokenServiceContract $refreshTokens = null, - private readonly int $accessTtl = 3600, - ) {} - - /** - * Mint a JWT access token for a user via AuthService. Port of the old - * AuthManager::issueToken(). Requires the AuthServiceContract to be wired. - * - * @param array{roles?:list,permissions?:list,tnt?:string} $claims - */ - public function issueToken(string $userId, array $claims = [], int $ttlSeconds = 3600): string - { - if ($this->auth === null) { - throw new ServiceException('AuthManager has no AuthService — cannot issue tokens.', layer: 'service.auth'); - } - - return $this->auth->issueJwt($userId, $claims, $ttlSeconds); - } - - /** - * Mint the full mobile/API credential pair for an ALREADY-VERIFIED user: - * a short-lived access JWT + a revocable refresh token. The single front-door - * call for stateless issuance (mobile login/register), so callers never touch - * AuthService / RefreshTokenService directly. - * - * @param array{roles?:list,permissions?:list,tnt?:string} $claims - * @return array{accessToken:string,tokenType:string,expiresAt:int,refreshToken:string,refreshExpiresAt:string} - */ - public function issueTokenPair(string $userId, array $claims = [], ?string $device = null, ?string $ip = null): array - { - if ($this->auth === null || $this->refreshTokens === null) { - throw new ServiceException( - 'AuthManager cannot issue a token pair — AuthService/RefreshTokenService not wired.', - layer: 'service.auth', - ); - } - - $accessToken = $this->auth->issueJwt($userId, $claims, $this->accessTtl); - $refresh = $this->refreshTokens->issue($userId, device: $device, ip: $ip); - - return [ - 'accessToken' => $accessToken, - 'tokenType' => 'Bearer', - 'expiresAt' => time() + $this->accessTtl, - 'refreshToken' => $refresh->token, - 'refreshExpiresAt' => $refresh->expiresAt, - ]; - } - - /** - * Bind the active request (the container-bearing one) and reset the guard - * cache. Called once per request by the controller concern; resetting the - * cache keeps a reused instance Swoole-safe. - */ - public function setRequest(Request $request): self - { - $this->request = $request; - $this->guards = []; - - return $this; - } - - // ── Guards ──────────────────────────────────────────────────────────────── - - public function guard(?string $name = null): GuardAccessor - { - $name ??= $this->defaultGuard(); - - return $this->guards[$name] ??= $this->buildGuard($name); - } - - /** The default guard's current user. */ - public function user(?string $name = null): ?Authenticatable - { - return $this->guard($name)->user(); - } - - public function check(?string $name = null): bool - { - return $this->guard($name)->check(); - } - - public function id(?string $name = null): string - { - return $this->guard($name)->id(); - } - - // ── Extension (custom guards / providers) ──────────────────────────────────── - - /** - * Register a custom guard creator. Takes precedence over config/scanned - * drivers for that guard name. Port of the old AuthManager::extend(). - * - * @param \Closure(?Request,string,array):GuardAccessor $creator - */ - public function extend(string $name, \Closure $creator): self - { - $this->customGuardCreators[$name] = $creator; - unset($this->guards[$name]); - - return $this; - } - - /** - * Register a custom user-provider creator. Port of the old - * AuthManager::provider(name, callback). - * - * @param \Closure(string):UserProvider $creator - */ - public function extendProvider(string $name, \Closure $creator): self - { - $this->customProviderCreators[$name] = $creator; - unset($this->providers[$name]); - - return $this; - } - - /** Shared resolver returning the current user for a guard (Gate/Request use it). */ - public function userResolver(): \Closure - { - return $this->userResolver ??= fn(?string $guard = null): ?Authenticatable => $this->guard($guard)->user(); - } - - /** Override the shared user resolver. Port of resolveUsersUsing(). */ - public function resolveUsersUsing(\Closure $resolver): self - { - $this->userResolver = $resolver; - - return $this; - } - - /** Drop cached guard instances — call at the start of a Swoole request cycle. */ - public function forgetGuards(): self - { - $this->guards = []; - - return $this; - } - - /** Forward unknown calls (check/user/id/identity/...) to the default guard. */ - public function __call(string $method, array $parameters): mixed - { - return $this->guard()->{$method}(...$parameters); - } - - // ── Providers ─────────────────────────────────────────────────────────────── - - /** Resolve a named UserProvider (default provider when null). */ - public function provider(?string $name = null): UserProvider - { - $name ??= $this->defaultProvider(); - - $provider = $this->providers[$name] ??= isset($this->customProviderCreators[$name]) - ? ($this->customProviderCreators[$name])($name) - : ($this->providerFactory)($name); - - if ($provider === null) { - throw new ServiceException("Auth provider [{$name}] is not configured.", layer: 'service.auth'); - } - - return $provider; - } - - // ── Internals ────────────────────────────────────────────────────────────── - - private function buildGuard(string $name): GuardAccessor - { - if ($this->request === null) { - throw new ServiceException('AuthManager has no request — call setRequest() first.', layer: 'service.auth'); - } - - // Custom creators registered via extend() take precedence. - if (isset($this->customGuardCreators[$name])) { - return ($this->customGuardCreators[$name])($this->request, $name, $this->config['guards'][$name] ?? []); - } - - $guardConfig = $this->config['guards'][$name] ?? null; - if (!\is_array($guardConfig)) { - throw new ServiceException("Auth guard [{$name}] is not defined in config/auth.php.", layer: 'service.auth'); - } - - $driverName = (string) ($guardConfig['driver'] ?? ''); - $driverClass = self::drivers()[$driverName] ?? null; - if ($driverClass === null) { - throw new ServiceException("Auth driver [{$driverName}] for guard [{$name}] was not found.", layer: 'service.auth'); - } - - $provider = $this->provider($guardConfig['provider'] ?? null); - $context = new GuardContext($provider, $this->session); - - /** @var GuardDriver $driver */ - $driver = new $driverClass(); - - // Stateful drivers also get the WRITE-side guard so the old ergonomics - // hold: $manager->guard('web')->attempt($credentials, $remember). - $stateful = $driverName === 'session' && $this->statefulFactory !== null - ? ($this->statefulFactory)($name, $provider, $this->request) - : null; - - return new GuardAccessor($name, $driver, $context, $this->request, $stateful); - } - - private function defaultGuard(): string - { - return (string) ($this->config['defaults']['guard'] ?? 'web'); - } - - private function defaultProvider(): string - { - return (string) ($this->config['defaults']['provider'] ?? 'users'); - } - - /** - * Filesystem-scan the Drivers directory once per process, mapping each - * GuardDriver implementation to its driverName(). Boot-time + cached. - * - * @return array> - */ - public static function drivers(): array - { - if (self::$driverMap !== null) { - return self::$driverMap; - } - - $map = []; - $dir = \dirname(__DIR__, 2) . '/Infrastructure/Auth/Drivers'; - - foreach (glob($dir . '/*.php') ?: [] as $file) { - $class = 'Plugins\\Auth\\Infrastructure\\Auth\\Drivers\\' . basename($file, '.php'); - - if (!class_exists($class)) { - continue; - } - if (!is_subclass_of($class, GuardDriver::class)) { - continue; // traits/abstracts (e.g. ResolvesFromVerdict) are skipped - } - - /** @var class-string $class */ - $map[$class::driverName()] = $class; - } - - return self::$driverMap = $map; - } - - /** Test seam — forget the scanned driver map (never call on the hot path). */ - public static function flushDriverCache(): void - { - self::$driverMap = null; - } -} diff --git a/plugins/Auth/Application/Auth/AuthUserProxy.php b/plugins/Auth/Application/Auth/AuthUserProxy.php deleted file mode 100644 index 32d159e..0000000 --- a/plugins/Auth/Application/Auth/AuthUserProxy.php +++ /dev/null @@ -1,200 +0,0 @@ -user(). - * - * Roles/permissions/tenant/credential-type are supplied by whichever guard - * resolved the user (the gateway verdict for token guards; the session store for - * the session guard), since the central User record does not itself model RBAC. - */ -final readonly class AuthUserProxy implements Authenticatable -{ - /** - * @param list $roles - * @param list $permissions - */ - private function __construct( - private string $userId, - private string $username, - private string $email, - private array $roles, - private array $permissions, - private string $tenantId, - private string $tokenType, - private string $joinedAt, - private ?AuthServiceContract $tokensService = null, - private ?TokenDTO $accessToken = null, - private string $fullName = '', - private ?string $avatarUrl = null, - ) {} - - /** - * Build from a UserDTO plus the security context resolved by the guard. Pass - * $tokensService to enable the HasApiTokens surface (tokens/createToken). - * - * @param list $roles - * @param list $permissions - */ - public static function fromUser( - UserDTO $user, - array $permissions = [], - string $tokenType = 'session', - ?AuthServiceContract $tokensService = null, - ): self { - return new self( - userId: $user->id, - username: $user->username, - email: $user->email, - roles: array_values($user->roles), - permissions: array_values($permissions), - tenantId: $user->tenantId ?? "", - tokenType: $tokenType, - tokensService: $tokensService, - joinedAt: $user->joinedAt ?? "", - fullName: $user->fullName, - avatarUrl: $user->avatarUrl, - ); - } - - /** - * Overlay the security context resolved by a guard (roles/permissions from - * the session or the gateway verdict), returning an immutable copy. - * - * @param list $roles - * @param list $permissions - */ - public function withSecurity(array $roles, array $permissions, string $tenantId, string $tokenType): self - { - return new self( - $this->userId, - $this->username, - $this->email, - array_values($roles), - array_values($permissions), - $tenantId, - $tokenType, - $this->joinedAt, - $this->tokensService, - $this->accessToken, - $this->fullName, - $this->avatarUrl, - ); - } - - /** Attach the access token this request authenticated with (immutable copy). */ - public function withAccessToken(TokenDTO $token): self - { - return new self( - $this->userId, - $this->username, - $this->email, - $this->roles, - $this->permissions, - $this->tenantId, - $this->tokenType, - $this->joinedAt, - $this->tokensService, - $token, - $this->fullName, - $this->avatarUrl, - ); - } - - // ── HasApiTokens (needs a tokens service; else degrades gracefully) ────────── - - /** - * All personal access tokens issued to this user (no secret material). - * - * @return list - */ - public function tokens(): array - { - return $this->tokensService?->tokensFor($this->userId) ?? []; - } - - /** The access token this request authenticated with, if any. */ - public function token(): ?TokenDTO - { - return $this->accessToken; - } - - /** - * Whether the current access token carries the given ability, OR — when no - * explicit token is attached — the user's permissions grant it (covers - * session/JWT callers). Mirrors the old tokenCan(). - */ - public function tokenCan(string $ability): bool - { - if ($this->accessToken !== null) { - return $this->accessToken->can($ability); - } - - return \Plugins\Auth\API\ScopeInheritance::satisfies($this->permissions, $ability); - } - - /** - * Mint a new personal access token for this user. - * - * @param list $abilities - * @return array{id:string,token:string} - */ - public function createToken(string $name, array $abilities = [], ?int $ttlSeconds = null): array - { - if ($this->tokensService === null) { - throw new \LogicException('AuthUserProxy has no tokens service — resolve it with a service to mint tokens.'); - } - - return $this->tokensService->createPersonalAccessToken($this->userId, $name, $abilities, $ttlSeconds); - } - - public function getAuthIdentifier(): string - { - return $this->userId; - } - - public function getAuthIdentifierName(): string - { - return 'user_id'; - } - - public function getUsername(): string - { - return $this->username; - } - - public function getEmail(): string - { - return $this->email; - } - - public function identity(): Identity - { - return new Identity( - userId: $this->userId, - tenantId: $this->tenantId, - roles: $this->roles, - permissions: $this->permissions, - tokenType: $this->tokenType, - username: $this->username, - email: $this->email, - fullName: $this->fullName, - avatarUrl: $this->avatarUrl, - ); - } -} diff --git a/plugins/Auth/Application/Auth/GuardAccessor.php b/plugins/Auth/Application/Auth/GuardAccessor.php deleted file mode 100644 index 84e7165..0000000 --- a/plugins/Auth/Application/Auth/GuardAccessor.php +++ /dev/null @@ -1,109 +0,0 @@ -guard('web')->user()` ergonomics. Resolves the - * user lazily once per accessor and caches it (request-scoped). - * - * Stateful guards ('session' driver) also carry the WRITE-side guard, so the - * full old flow works through one handle: - * - * $manager->guard('web')->attempt(['email' => …, 'password' => …], remember: true); - * $manager->guard('web')->logout(); - * $manager->guard('web')->logoutOtherDevices($password); - * - * Write calls are forwarded via __call; a stateless guard (api/jwt/request) - * throws a descriptive ServiceException instead of silently no-opping. - */ -final class GuardAccessor -{ - private bool $resolved = false; - private ?Authenticatable $user = null; - - public function __construct( - private readonly string $name, - private readonly GuardDriver $driver, - private readonly GuardContext $context, - private readonly Request $request, - private readonly ?StatefulGuard $stateful = null, - ) {} - - /** - * The WRITE-side guard (attempt/login/logout/…), when this guard is - * stateful. Null for token-style guards. - */ - public function stateful(): ?StatefulGuard - { - return $this->stateful; - } - - /** Forward write operations (attempt/login/logout/…) to the stateful guard. */ - public function __call(string $method, array $parameters): mixed - { - if ($this->stateful === null) { - throw new ServiceException( - "Auth guard [{$this->name}] is stateless — [{$method}] requires a session guard.", - layer: 'service.auth', - ); - } - - $result = $this->stateful->{$method}(...$parameters); - - // A write may have changed who is logged in — drop the cached read. - $this->resolved = false; - $this->user = null; - - return $result; - } - - /** Guard name (e.g. 'web', 'api'). */ - public function name(): string - { - return $this->name; - } - - /** The authenticated user, or null. Resolved once and cached. */ - public function user(): ?Authenticatable - { - if (!$this->resolved) { - $this->user = $this->driver->resolve($this->request, $this->context); - $this->resolved = true; - } - - return $this->user; - } - - public function check(): bool - { - return $this->user() !== null; - } - - public function guest(): bool - { - return $this->user() === null; - } - - /** The current user's id, or '' when unauthenticated. */ - public function id(): string - { - return $this->user()?->getAuthIdentifier() ?? ''; - } - - /** Kernel Identity for the current user (guest Identity when unauthenticated). */ - public function identity(): Identity - { - return $this->user()?->identity() ?? Identity::guest(); - } -} diff --git a/plugins/Auth/Application/Auth/GuardBehaviour.php b/plugins/Auth/Application/Auth/GuardBehaviour.php deleted file mode 100644 index 01655fa..0000000 --- a/plugins/Auth/Application/Auth/GuardBehaviour.php +++ /dev/null @@ -1,78 +0,0 @@ -user(); - if ($user === null) { - throw new AuthenticationException(guards: [method_exists($this, 'getName') ? $this->getName() : 'default']); - } - - return $user; - } - - /** Whether a user has already been resolved for this request (no lookup). */ - public function hasUser(): bool - { - return $this->user !== null; - } - - public function check(): bool - { - return $this->user() !== null; - } - - public function guest(): bool - { - return !$this->check(); - } - - public function id(): ?string - { - return $this->user()?->getAuthIdentifier(); - } - - public function setUser(Authenticatable $user): static - { - $this->user = $user; - - return $this; - } - - public function forgetUser(): static - { - $this->user = null; - - return $this; - } - - public function getProvider(): UserProvider - { - return $this->provider; - } - - public function setProvider(UserProvider $provider): static - { - $this->provider = $provider; - - return $this; - } -} diff --git a/plugins/Auth/Application/Auth/ModelUserProvider.php b/plugins/Auth/Application/Auth/ModelUserProvider.php deleted file mode 100644 index f0686f0..0000000 --- a/plugins/Auth/Application/Auth/ModelUserProvider.php +++ /dev/null @@ -1,99 +0,0 @@ - $lookupFields ordered credential keys to try - */ - public function __construct( - private readonly UserServiceContract $users, - private readonly string $providerName = 'users', - private readonly array $lookupFields = ['identifier', 'email', 'username'], - private readonly ?AuthServiceContract $tokens = null, - ) {} - - public function name(): string - { - return $this->providerName; - } - - public function retrieveById(string $id): ?Authenticatable - { - if ($id === '') { - return null; - } - - return $this->proxy($this->users->find($id, true,true)); - } - - public function retrieveByToken(string $rememberToken): ?Authenticatable - { - return $this->proxy($this->users->findByRememberToken($rememberToken)); - } - - public function retrieveByCredentials(array $credentials): ?Authenticatable - { - $identifier = ''; - foreach ($this->lookupFields as $field) { - if (($credentials[$field] ?? '') !== '') { - $identifier = (string) $credentials[$field]; - break; - } - } - - $password = (string) ($credentials['password'] ?? ''); - if ($identifier === '' || $password === '') { - return null; - } - - $user = $this->users->verifyCredentials($identifier, $password); - - - // Single timing-safe verify (unknown user, wrong password, inactive, or - // lockout all return null). The store never exposes the hash. - return $this->proxy($user); - } - - /** - * Wrap a fetched user in the auth proxy. Membership was already enforced by - * the User contract during the fetch, so a user without an active seat in - * the request's tenant arrives here as null — indistinguishable from a - * non-existent user. - */ - private function proxy(?\Plugins\User\API\DTOs\UserDTO $user): ?Authenticatable - { - if ($user === null) { - return null; - } - - return AuthUserProxy::fromUser( - $user, - tokensService: $this->tokens, - ); - } -} diff --git a/plugins/Auth/Application/Auth/PasswordResetBroker.php b/plugins/Auth/Application/Auth/PasswordResetBroker.php deleted file mode 100644 index 539f0f0..0000000 --- a/plugins/Auth/Application/Auth/PasswordResetBroker.php +++ /dev/null @@ -1,179 +0,0 @@ -users->findByIdentifier($email); - if ($user === null) { - return ['status' => self::INVALID_USER]; - } - - if ($this->cache->has(self::THROTTLE_PREFIX . $this->key($email))) { - return ['status' => self::THROTTLED]; - } - - $token = bin2hex(random_bytes(32)); - $this->cache->set(self::TOKEN_PREFIX . $this->key($email), hash('sha256', $token), $this->ttlSeconds); - $this->cache->set(self::THROTTLE_PREFIX . $this->key($email), 1, $this->throttleSeconds); - - return [ - 'status' => self::RESET_LINK_SENT, - 'token' => $token, - 'userId' => $user->id, - 'email' => $email, - ]; - } - - public function validateToken(string $email, string $token): bool - { - $email = mb_strtolower(trim($email)); - $stored = $this->cache->get(self::TOKEN_PREFIX . $this->key($email)); - - return is_string($stored) && $stored !== '' && hash_equals($stored, hash('sha256', $token)); - } - - public function reset(string $email, string $token, string $newPassword): string - { - $email = mb_strtolower(trim($email)); - - if (!$this->validateToken($email, $token)) { - return self::INVALID_TOKEN; - } - - $user = $this->users->findByIdentifier($email); - if ($user === null) { - return self::INVALID_USER; - } - - if (!$this->users->resetPassword($user->id, $newPassword)) { - return self::INVALID_USER; - } - - // A reset is a compromise-recovery action, so every credential minted - // under the OLD password has to die with it — otherwise an attacker who - // is already signed in (or holds a refresh token) keeps access and the - // reset achieves nothing. UserService clears the remember-me token; - // refresh tokens and device sessions are Auth's own and are swept here. - // - // Deliberately BEFORE the token burn: if a sweep fails we do not report - // success, and the reset token stays valid so the user can retry. The - // password write is idempotent, so a retry is harmless. - $this->refreshTokens?->revokeAllForUser($user->id); - $this->devices?->revokeAll($user->id); - - // One-time use: burn the token (and the throttle) on success. - $this->cache->delete(self::TOKEN_PREFIX . $this->key($email)); - $this->cache->delete(self::THROTTLE_PREFIX . $this->key($email)); - // Also burn any OTP still outstanding for this account — the reset is - // done, so a code sitting in an inbox must not open a second window. - $this->cache->delete(self::OTP_PREFIX . $this->key($email)); - - return self::PASSWORD_RESET; - } - - // ── OTP mode (old __DEV__ mobile forgot-password flow) ────────────────────── - - public function sendOtp(string $email): ?array - { - $result = $this->sendResetLink($email); - if (($result['status'] ?? '') !== self::RESET_LINK_SENT || !isset($result['token'])) { - return null; // unknown user or throttled — caller responds generically - } - - // 6-digit OTP paired with the underlying reset token: "otp|token", - // short-lived and single-use (consumed by verifyOtp). - $otp = str_pad((string) random_int(0, 999_999), 6, '0', STR_PAD_LEFT); - $this->cache->set( - self::OTP_PREFIX . $this->key((string) $result['email']), - $otp . '|' . $result['token'], - $this->otpTtlSeconds, - ); - // A newly issued code starts with a clean guess budget. - $this->cache->delete(self::ATTEMPT_PREFIX . $this->key((string) $result['email'])); - - return ['otp' => $otp, 'email' => (string) $result['email']]; - } - - public function verifyOtp(string $email, string $otp): ?string - { - $email = mb_strtolower(trim($email)); - $key = $this->key($email); - $cached = $this->cache->get(self::OTP_PREFIX . $key); - - if (!is_string($cached) || $cached === '') { - return null; - } - - [$storedOtp, $token] = explode('|', $cached, 2) + [1 => '']; - if ($token === '' || !hash_equals($storedOtp, trim($otp))) { - // Cap guesses PER ACCOUNT. The route throttle is per IP, and the code - // space is only 10^6 — a distributed guesser would otherwise get - // effectively unlimited tries inside the OTP's whole lifetime. - $attempts = $this->cache->increment(self::ATTEMPT_PREFIX . $key); - - if ($attempts === 1) { - // Re-set with a TTL so a stale counter can never outlive the code - // and burn a LATER, legitimate OTP. increment() carries no TTL. - $this->cache->set(self::ATTEMPT_PREFIX . $key, 1, $this->otpTtlSeconds); - } - - if ($attempts >= $this->maxOtpAttempts) { - $this->cache->delete(self::OTP_PREFIX . $key); - $this->cache->delete(self::ATTEMPT_PREFIX . $key); - } - - return null; - } - - // Single-use: burn the OTP; the underlying token stays valid for reset(). - $this->cache->delete(self::OTP_PREFIX . $key); - $this->cache->delete(self::ATTEMPT_PREFIX . $key); - - return $token; - } - - private function key(string $email): string - { - return hash('sha256', $email); - } -} diff --git a/plugins/Auth/Application/Auth/PersonalAccessTokenFactory.php b/plugins/Auth/Application/Auth/PersonalAccessTokenFactory.php deleted file mode 100644 index 53c97cb..0000000 --- a/plugins/Auth/Application/Auth/PersonalAccessTokenFactory.php +++ /dev/null @@ -1,38 +0,0 @@ - $scopes token abilities - */ - public function make(string $userId, string $name, array $scopes = [], ?int $ttlSeconds = null): PersonalAccessTokenResult - { - $created = $this->auth->createPersonalAccessToken($userId, $name, $scopes, $ttlSeconds); - - $token = new TokenDTO( - id: $created['id'], - name: $name, - abilities: array_values($scopes), - expiresAt: $ttlSeconds !== null ? (new \DateTimeImmutable())->add(new \DateInterval('PT' . $ttlSeconds . 'S')) : null, - lastUsedAt: null, - createdAt: new \DateTimeImmutable(), - ); - - return new PersonalAccessTokenResult($created['token'], $token); - } -} diff --git a/plugins/Auth/Application/Auth/RoleResolver.php b/plugins/Auth/Application/Auth/RoleResolver.php deleted file mode 100644 index d5158d5..0000000 --- a/plugins/Auth/Application/Auth/RoleResolver.php +++ /dev/null @@ -1,42 +0,0 @@ -hasRole/hasPermission, the `can` filter, service gates) see - * the same picture. When Authorization is absent it degrades to empty lists — - * auth still works, there is simply no RBAC data to carry. - */ -final class RoleResolver -{ - public function __construct( - private readonly ?AuthorizationServiceContract $authz = null, - ) { - } - - /** - * @return array{roles: list, permissions: list} - */ - public function forUser(string $userId, string $tenantId = ''): array - { - if ($this->authz === null || $userId === '') { - return ['roles' => [], 'permissions' => []]; - } - - $domain = $tenantId !== '' ? $tenantId : null; - - return [ - 'roles' => $this->authz->rolesOf($userId, $domain), - 'permissions' => $this->authz->permissionsOf($userId, $domain), - ]; - } -} diff --git a/plugins/Auth/Application/Auth/StatefulSessionGuard.php b/plugins/Auth/Application/Auth/StatefulSessionGuard.php deleted file mode 100644 index 96a898e..0000000 --- a/plugins/Auth/Application/Auth/StatefulSessionGuard.php +++ /dev/null @@ -1,345 +0,0 @@ -provider = $provider; - } - - public function setRequest(Request $request): self - { - $this->request = $request; - $this->user = null; - $this->viaRemember = false; - - return $this; - } - - public function getName(): string - { - return $this->name; - } - - // ── Resolution ────────────────────────────────────────────────────────────── - - /** The current user: session first, then a remember-me recaller cookie. */ - public function user(): ?Authenticatable - { - if ($this->user !== null) { - return $this->user; - } - - $userId = (string) $this->session->get(AuthService::SESSION_USER, ''); - if ($userId !== '') { - // Fingerprint + server-side device-session validation (old __DEV__ - // semantics): a hijacked or revoked session dies here, immediately. - if ($this->devices !== null && $this->request !== null - && !$this->devices->verify($this->session, $this->request)) { - $this->devices->teardown($this->session); - $this->session->invalidate(); - - return null; - } - - $base = $this->provider->retrieveById($userId); - if ($base instanceof AuthUserProxy) { - $base = $base->withSecurity( - $this->stringList($this->session->get(AuthService::SESSION_ROLES, [])), - $this->stringList($this->session->get(AuthService::SESSION_PERMISSIONS, [])), - (string) $this->session->get(AuthService::SESSION_TENANT, ''), - 'session', - ); - } - - return $this->user = $base; - } - - return $this->user = $this->userFromRecaller(); - } - - // ── Login flows ───────────────────────────────────────────────────────────── - - public function attempt(array $credentials = [], bool $remember = false): bool - { - $user = $this->provider->retrieveByCredentials($credentials); - - $this->lastAttempted = $user; - - if ($user === null) { - return false; - } - - $this->login($user, $remember); - - return true; - } - - public function validate(array $credentials = []): bool - { - $user = $this->provider->retrieveByCredentials($credentials); - $this->lastAttempted = $user; - - return $user !== null; - } - - public function once(array $credentials = []): bool - { - if (!$this->validate($credentials) || $this->lastAttempted === null) { - return false; - } - - $this->setUser($this->lastAttempted); - - return true; - } - - public function onceUsingId(string $id): Authenticatable|false - { - $user = $this->provider->retrieveById($id); - if ($user === null) { - return false; - } - - $this->setUser($user); - - return $user; - } - - public function loginUsingId(string $id, bool $remember = false): Authenticatable|false - { - $user = $this->provider->retrieveById($id); - if ($user === null) { - return false; - } - - $this->login($user, $remember); - - return $user; - } - - public function login(Authenticatable $user, bool $remember = false): void - { - $identity = $user->identity(); - - // Session-fixation defence: rotate on privilege change. - $this->session->regenerate(); - $this->session->put(AuthService::SESSION_USER, $identity->userId); - $this->session->put(AuthService::SESSION_ROLES, $identity->roles); - $this->session->put(AuthService::SESSION_PERMISSIONS, $identity->permissions); - $this->session->put(AuthService::SESSION_TENANT, $identity->tenantId); - $this->session->put(AuthService::SESSION_USERNAME, $identity->username); - $this->session->put(AuthService::SESSION_EMAIL, $identity->email); - $this->session->put(AuthService::SESSION_NAME, $identity->fullName); - $this->session->put(AuthService::SESSION_AVATAR, $identity->avatarUrl); - - // Bind the session to this device: fingerprint + auth_sessions row. - if ($this->devices !== null && $this->request !== null) { - $this->devices->establish($this->session, $this->request, $identity->userId); - } - - if ($remember) { - $this->queueRecaller($identity->userId); - } - - $this->setUser($user); - } - - // ── Logout ──────────────────────────────────────────────────────────────── - - public function logout(): void - { - $userId = $this->id(); - - if ($userId !== null && $userId !== '') { - $this->users->clearRememberToken($userId); - } - $this->cookies?->forget($this->recallerCookie); - $this->devices?->teardown($this->session); - $this->session->invalidate(); - $this->forgetUser(); - $this->viaRemember = false; - } - - /** - * Invalidate every OTHER session/remember-me for this user by cycling the - * remember token, after re-verifying the password. Returns the current user. - */ - public function logoutOtherDevices(string $password): ?Authenticatable - { - $user = $this->user(); - if ($user === null) { - return null; - } - - // Re-verify the password before rotating (defence against a hijacked session). - if ($this->users->verifyCredentials($user->getEmail(), $password) === null - && $this->users->verifyCredentials($user->getUsername(), $password) === null) { - return null; - } - - // Kill every OTHER device's server-side session (old semantics), then - // rotate the remember token so outstanding recaller cookies die too - // (queueRecaller cycles the token before issuing this device's cookie). - if ($this->devices !== null && $this->request !== null) { - $this->devices->revokeOthers($this->session, $this->request, $user->getAuthIdentifier()); - } - - $this->queueRecaller($user->getAuthIdentifier()); // rotates + reissues for THIS device - - return $user; - } - - public function viaRemember(): bool - { - return $this->viaRemember; - } - - // ── HTTP Basic auth ───────────────────────────────────────────────────────── - - public function basic(string $field = 'email', array $extraConditions = []): ?Response - { - if ($this->check()) { - return null; - } - - $creds = $this->basicCredentials($field, $extraConditions); - if ($creds !== null && $this->attempt($creds)) { - return null; - } - - return $this->basicChallenge(); - } - - public function onceBasic(string $field = 'email', array $extraConditions = []): ?Response - { - $creds = $this->basicCredentials($field, $extraConditions); - if ($creds !== null && $this->once($creds)) { - return null; - } - - return $this->basicChallenge(); - } - - /** @return array|null decoded Basic credentials */ - private function basicCredentials(string $field, array $extraConditions): ?array - { - $header = $this->request?->header('Authorization') ?? ''; - if (!str_starts_with($header, 'Basic ')) { - return null; - } - - $decoded = base64_decode(substr($header, 6), true); - if ($decoded === false || !str_contains($decoded, ':')) { - return null; - } - - [$user, $password] = explode(':', $decoded, 2); - - return [$field => $user, 'password' => $password] + $extraConditions; - } - - private function basicChallenge(): Response - { - return Response::unauthorized('Invalid credentials.') - ->withHeader('WWW-Authenticate', 'Basic realm="' . $this->name . '"'); - } - - public function getLastAttempted(): ?Authenticatable - { - return $this->lastAttempted; - } - - // ── Remember-me internals ──────────────────────────────────────────────────── - - private function userFromRecaller(): ?Authenticatable - { - if ($this->cookies === null || $this->request === null) { - return null; - } - - $raw = $this->cookies->read($this->request, $this->recallerCookie); - if ($raw === null || $raw === '') { - return null; - } - - $recaller = new Recaller($raw); - if (!$recaller->valid()) { - return null; - } - - $user = $this->provider->retrieveByToken($recaller->token()); - if ($user === null || $user->getAuthIdentifier() !== $recaller->id()) { - return null; - } - - // Rotate on use, then re-open the session so subsequent requests are cheap. - $this->viaRemember = true; - $this->login($user, true); - - return $user; - } - - private function queueRecaller(string $userId): void - { - if ($this->cookies === null) { - return; - } - - $token = $this->users->cycleRememberToken($userId); - $this->cookies->queue( - $this->recallerCookie, - Recaller::make($userId, $token)->value(), - maxAge: $this->rememberTtl, - ); - } - - /** @return list */ - private function stringList(mixed $value): array - { - return \is_array($value) ? array_values(array_filter($value, 'is_string')) : []; - } -} diff --git a/plugins/Auth/Application/Ports/Authenticatable.php b/plugins/Auth/Application/Ports/Authenticatable.php deleted file mode 100644 index 26dabcd..0000000 --- a/plugins/Auth/Application/Ports/Authenticatable.php +++ /dev/null @@ -1,41 +0,0 @@ - $credentials - */ - public function attempt(array $credentials = [], bool $remember = false): bool; - - /** - * Validate credentials WITHOUT logging in (no session written). - * - * @param array $credentials - */ - public function validate(array $credentials = []): bool; - - /** - * Authenticate for a SINGLE request without persisting a session. - * - * @param array $credentials - */ - public function once(array $credentials = []): bool; - - /** Log the given user in and persist the session (+ optional remember-me). */ - public function login(Authenticatable $user, bool $remember = false): void; - - /** Log a user in by id; false when no such user. */ - public function loginUsingId(string $id, bool $remember = false): Authenticatable|false; - - /** Set the user for a single request by id (no session write). */ - public function onceUsingId(string $id): Authenticatable|false; - - /** True when the current user was authenticated via a remember-me cookie. */ - public function viaRemember(): bool; - - /** Log the current user out (clears session + remember-me). */ - public function logout(): void; -} diff --git a/plugins/Auth/Application/Ports/SupportsBasicAuth.php b/plugins/Auth/Application/Ports/SupportsBasicAuth.php deleted file mode 100644 index 29f40eb..0000000 --- a/plugins/Auth/Application/Ports/SupportsBasicAuth.php +++ /dev/null @@ -1,21 +0,0 @@ -jwtAlgo[0] === 'R' || $this->jwtAlgo[0] === 'E' || $this->jwtAlgo[0] === 'P'; - } - - public function issueJwt(string $userId, array $claims = [], int $ttlSeconds = 3600): string - { - $asymmetric = $this->isAsymmetric(); - $signingKey = $asymmetric ? (string) $this->jwtPrivateKey : $this->jwtSecret; - - if ($signingKey === '') { - throw new ServiceException('auth.jwt.unconfigured', layer: 'service.auth'); - } - - // Tenant context travels on the signed `tnt` claim. Mint it ONLY after - // the user selects a tenant and membership is verified against the - // central `user_tenants` table; an access token issued at login carries - // no tenant (empty) so it routes to the central connection only. - // RBAC enrichment: when the caller didn't supply roles/permissions and - // the Authorization plugin is loaded, resolve them from the policy store - // so the access token carries the user's effective grants. - $tenant = (string) ($claims['tnt'] ?? $claims['tenant'] ?? ''); - if (!isset($claims['roles']) && !isset($claims['permissions']) && $this->roles !== null) { - $resolved = $this->roles->forUser($userId, $tenant); - $claims['roles'] = $resolved['roles']; - $claims['permissions'] = $resolved['permissions']; - } - - // Display-identity claims (OIDC names) — ride on the signed token so the - // stateless JwtAuthLayer can rebuild a full Identity without a DB read. - // username/email come from the central user record when the caller did - // not supply them; `name` (first + last) lives in the TENANT - // user_profiles table, so only a tenant-aware caller (tenant selection) - // can mint it — best-effort, never blocks issuance. - [$username, $email] = $this->displayIdentity( - $userId, - (string) ($claims['preferred_username'] ?? ''), - (string) ($claims['email'] ?? ''), - ); - $fullName = (string) ($claims['name'] ?? ''); - - $now = time(); - $payload = [ - 'sub' => $userId, - 'tnt' => $tenant, - 'roles' => array_values($claims['roles'] ?? []), - 'permissions' => array_values($claims['permissions'] ?? []), - 'iat' => $now, - 'nbf' => $now, - 'exp' => $now + max(1, $ttlSeconds), - // Unique token id — enables targeted revocation / replay tracking. - 'jti' => bin2hex(random_bytes(16)), - ]; - - if ($username !== '') { - $payload['preferred_username'] = $username; - } - if ($email !== '') { - $payload['email'] = $email; - } - if ($fullName !== '') { - $payload['name'] = $fullName; - } - - // Registered claims for issuer/audience binding (verified by JwtAuthLayer). - if ($this->jwtIssuer !== null && $this->jwtIssuer !== '') { - $payload['iss'] = $this->jwtIssuer; - } - if ($this->jwtAudience !== null && $this->jwtAudience !== '') { - $payload['aud'] = $this->jwtAudience; - } - - $kid = ($this->jwtKid !== null && $this->jwtKid !== '') ? $this->jwtKid : null; - - return JWT::encode($payload, $signingKey, $this->jwtAlgo, $kid); - } - - public function guard(Request $request): Guard - { - return Guard::fromRequest($request); - } - - public function tokensFor(string $userId): array - { - return array_map( - static fn (array $row): TokenDTO => TokenDTO::fromRow($row), - $this->tokens->findByUser($userId), - ); - } - - public function createPersonalAccessToken( - string $userId, - string $name = 'default', - array $abilities = [], - ?int $ttlSeconds = null, - ): array { - $id = bin2hex(random_bytes(16)); - $plaintext = $id . '.' . bin2hex(random_bytes(32)); - $hash = hash('sha256', $plaintext); - - $expiresAt = $ttlSeconds !== null && $ttlSeconds > 0 - ? (new \DateTimeImmutable())->add(new \DateInterval('PT' . $ttlSeconds . 'S')) - : null; - - $this->transactional(fn () => $this->tokens->store($id, $userId, $name, $hash, $abilities, $expiresAt)); - - return ['id' => $id, 'token' => $plaintext]; - } - - public function revokePersonalAccessToken(string $id): void - { - $this->transactional(fn () => $this->tokens->delete($id)); - } - - public function startSession( - SessionPort $session, - string $userId, - array $roles = [], - array $permissions = [], - string $tenantId = '', - string $username = '', - string $email = '', - string $fullName = '', - ?string $avatarUrl = null, - ): void { - // RBAC enrichment: when the caller passes no explicit roles/permissions - // and Authorization is loaded, resolve the user's effective grants so the - // session Identity carries them (parity with issueJwt()). - if ($roles === [] && $permissions === [] && $this->roles !== null) { - $resolved = $this->roles->forUser($userId, $tenantId); - $roles = $resolved['roles']; - $permissions = $resolved['permissions']; - } - - // Display-identity enrichment — same policy as issueJwt(): fill - // username/email from the central record when not supplied. - [$username, $email] = $this->displayIdentity($userId, $username, $email); - - // Session-fixation defence: rotate the id whenever the privilege level - // changes (anonymous → authenticated). Existing flash data is preserved. - $session->regenerate(); - - $session->put(self::SESSION_USER, $userId); - $session->put(self::SESSION_ROLES, array_values($roles)); - $session->put(self::SESSION_PERMISSIONS, array_values($permissions)); - $session->put(self::SESSION_TENANT, $tenantId); - $session->put(self::SESSION_USERNAME, $username); - $session->put(self::SESSION_EMAIL, $email); - $session->put(self::SESSION_NAME, $fullName); - $session->put(self::SESSION_AVATAR, $avatarUrl); - } - - public function endSession(SessionPort $session): void - { - // Drop every attribute AND rotate the id so the old cookie is dead. - $session->invalidate(); - } - - public function revokeJwt(string $jti, int $ttlSeconds = 3600): void - { - if ($jti === '' || $this->cache === null) { - return; - } - - // Keep the deny-list entry at least as long as the token's remaining - // life so it cannot be replayed after the cache entry would lapse. - $this->cache->set(JwtAuthLayer::revocationKey($jti), 1, max(1, $ttlSeconds)); - } - - public function hashPassword(string $plain): string - { - return $this->hasher->make($plain); - } - - public function verifyPassword(string $plain, string $hash): bool - { - return $this->hasher->check($plain, $hash); - } - - /** - * Fill username/email from the central user record when the caller did not - * supply them. Best-effort: a lookup failure never blocks credential - * issuance — the credential simply carries no display claims. - * - * @return array{0:string,1:string} [username, email] - */ - private function displayIdentity(string $userId, string $username, string $email): array - { - if (($username !== '' && $email !== '') || $this->users === null || $userId === '') { - return [$username, $email]; - } - - try { - /** @var ?\Plugins\User\API\Contracts\UserServiceContract $service */ - $service = ($this->users)(); - // isAuth: issuance happens while the request Identity is still - // guest — the self-or-permission check would reject the lookup. - $user = $service?->find($userId, false, true); - } catch (\Throwable) { - return [$username, $email]; - } - - if ($user === null) { - return [$username, $email]; - } - - return [ - $username !== '' ? $username : $user->username, - $email !== '' ? $email : $user->email, - ]; - } - - /** - * Bracket a unit of work in a transaction on the central auth connection. - * Nesting-aware (TransactionManager), and a straight pass-through when no - * manager was injected (unit tests with in-memory stores). - */ - private function transactional(callable $work): mixed - { - if ($this->transaction === null) { - return $work(); - } - - $this->transaction->begin(); - try { - $result = $work(); - $this->transaction->commit(); - - return $result; - } catch (\Throwable $e) { - $this->transaction->rollback(); - throw $e; - } - } -} diff --git a/plugins/Auth/Application/Services/DeviceSessionService.php b/plugins/Auth/Application/Services/DeviceSessionService.php deleted file mode 100644 index a0bd31e..0000000 --- a/plugins/Auth/Application/Services/DeviceSessionService.php +++ /dev/null @@ -1,283 +0,0 @@ -header($this->fingerprintHeader) ?? ''); - if ($client !== '') { - return hash('sha256', $client); - } - - $ip = (string) ($request->getClientIp() ?? '0.0.0.0'); - $ua = (string) ($request->header('User-Agent') ?? ''); - - return hash('sha256', $ip . '|' . $ua); - } - - // ── Lifecycle ─────────────────────────────────────────────────────────────── - - /** - * Bind the freshly-authenticated session to this device: store the - * fingerprint and open a device-session row. Call right after - * AuthService::startSession(). - */ - public function establish(SessionPort $session, Request $request, string $userId): void - { - $session->put(self::SESSION_FINGERPRINT, $this->fingerprint($request)); - - $opened = $this->open($request, $userId); - $session->put(self::SESSION_DEVICE_TOKEN, $opened['token']); - } - - /** - * Open a device-session row and return its public id + RAW token (the only - * time the raw token exists outside the PHP session). - * - * @return array{id:string,token:string} - */ - public function open(Request $request, string $userId): array - { - $sessionId = bin2hex(random_bytes(16)); - $token = bin2hex(random_bytes(32)); - - $this->transactional(fn () => $this->sessions->insert( - sessionId: $sessionId, - userId: $userId, - tokenHash: hash('sha256', $token), - fingerprint: $this->fingerprint($request), - ip: $request->getClientIp(), - userAgent: $request->header('User-Agent'), - expiresAt: $this->expiry(), - )); - - return ['id' => $sessionId, 'token' => $token]; - } - - /** - * Verify that the session still belongs to this device and is still live - * server-side. True when valid; false ⇒ the caller MUST tear the session down. - * - * Backward-compatible: a session with no stored fingerprint / device token - * (opened before this feature, or a bare startSession()) passes. - */ - public function verify(SessionPort $session, Request $request): bool - { - $stored = (string) $session->get(self::SESSION_FINGERPRINT, ''); - if ($stored !== '' && !hash_equals($stored, $this->fingerprint($request))) { - return false; - } - - $token = (string) $session->get(self::SESSION_DEVICE_TOKEN, ''); - if ($token === '') { - return true; - } - - $row = $this->sessions->findActiveByHash(hash('sha256', $token)); - if ($row === null) { - return false; - } - - $now = new \DateTimeImmutable(); - $expiresAt = new \DateTimeImmutable((string) $row['expires_at']); - if ($expiresAt <= $now) { - return false; - } - - // Rolling refresh: once inside the refresh window, slide the expiry a - // full TTL forward. Otherwise just stamp last-seen (rate-limited). - $refreshFrom = $expiresAt->sub(new \DateInterval('P' . max(1, $this->refreshDays) . 'D')); - if ($now >= $refreshFrom) { - $this->sessions->touch((string) $row['session_id'], $this->expiry()); - } elseif ($this->lastSeenIsStale($row['last_seen_at'] ?? null, $now)) { - $this->sessions->touch((string) $row['session_id']); - } - - return true; - } - - /** Revoke this device's server-side session (logout). */ - public function teardown(SessionPort $session): void - { - $token = (string) $session->get(self::SESSION_DEVICE_TOKEN, ''); - if ($token !== '') { - $this->transactional(fn () => $this->sessions->revokeByHash(hash('sha256', $token))); - } - - $session->forget(self::SESSION_DEVICE_TOKEN); - $session->forget(self::SESSION_FINGERPRINT); - } - - /** - * Revoke every OTHER device session for the user, keeping the current - * device's row alive (old logoutOtherDevices semantics). Returns the number - * of sessions revoked. - */ - public function revokeOthers(SessionPort $session, Request $request, string $userId): int - { - $token = (string) $session->get(self::SESSION_DEVICE_TOKEN, ''); - $currentId = null; - - if ($token !== '') { - $row = $this->sessions->findActiveByHash(hash('sha256', $token)); - $currentId = $row !== null ? (string) $row['session_id'] : null; - } - - // One transaction: the sweep and the replacement row commit together, - // so a failure cannot leave the user with every device signed out AND - // no registered session (the shared manager nests establish → open). - return $this->transactional(function () use ($session, $request, $userId, $currentId): int { - $revoked = $this->sessions->revokeAllForUser($userId, $currentId); - - // No live row for this device (pre-feature session) — open one so - // the user keeps a registered session after the sweep. - if ($currentId === null) { - $this->establish($session, $request, $userId); - } - - return $revoked; - }); - } - - // ── Device listing / targeted revocation ──────────────────────────────────── - - /** - * Active sessions for a user, flagging the caller's own device. - * - * @return list> - */ - public function listDevices(string $userId, ?SessionPort $session = null): array - { - $currentId = null; - $token = $session !== null ? (string) $session->get(self::SESSION_DEVICE_TOKEN, '') : ''; - if ($token !== '') { - $row = $this->sessions->findActiveByHash(hash('sha256', $token)); - $currentId = $row !== null ? (string) $row['session_id'] : null; - } - - return array_map(static fn (array $row): array => [ - 'id' => $row['session_id'], - 'ip' => $row['ip'], - 'userAgent' => $row['user_agent'], - 'lastSeen' => $row['last_seen_at'], - 'createdAt' => $row['created_at'], - 'expiresAt' => $row['expires_at'], - 'current' => $row['session_id'] === $currentId, - ], $this->sessions->listActiveForUser($userId)); - } - - /** Revoke one of the user's sessions by public id. True when it existed. */ - public function revokeById(string $userId, string $sessionId): bool - { - return $this->transactional(fn (): bool => $this->sessions->revokeForUser($userId, $sessionId)); - } - - /** - * Revoke EVERY session for a user, the caller's own included. - * - * Unlike revokeOthers(), this keeps no session alive and needs no SessionPort - * — it is for credential-recovery paths (password reset) where the request is - * unauthenticated and no existing session can be trusted: whoever knew the - * old password may still be signed in somewhere. - */ - public function revokeAll(string $userId): int - { - return $this->transactional(fn (): int => $this->sessions->revokeAllForUser($userId, null)); - } - - // ── Internals ─────────────────────────────────────────────────────────────── - - /** - * Bracket a unit of work in a transaction on the central auth connection. - * Nesting-aware (TransactionManager), and a straight pass-through when no - * manager was injected (unit tests with in-memory stores). - */ - private function transactional(callable $work): mixed - { - if ($this->transaction === null) { - return $work(); - } - - $this->transaction->begin(); - try { - $result = $work(); - $this->transaction->commit(); - - return $result; - } catch (\Throwable $e) { - $this->transaction->rollback(); - throw $e; - } - } - - private function expiry(): \DateTimeImmutable - { - return (new \DateTimeImmutable())->add(new \DateInterval('P' . max(1, $this->ttlDays) . 'D')); - } - - private function lastSeenIsStale(mixed $lastSeenAt, \DateTimeImmutable $now): bool - { - if (!is_string($lastSeenAt) || $lastSeenAt === '') { - return true; - } - - try { - $seen = new \DateTimeImmutable($lastSeenAt); - } catch (\Exception) { - return true; - } - - return ($now->getTimestamp() - $seen->getTimestamp()) >= self::TOUCH_INTERVAL_SECONDS; - } -} diff --git a/plugins/Auth/Application/Services/MobileAuthService.php b/plugins/Auth/Application/Services/MobileAuthService.php deleted file mode 100644 index 916b8d0..0000000 --- a/plugins/Auth/Application/Services/MobileAuthService.php +++ /dev/null @@ -1,126 +0,0 @@ - $oauthParams client_id, redirect_uri, scope, - * state, code_challenge, code_challenge_method - * @return array{code:string,state:string} - * @throws \Plugins\OAuth2\Domain\Exceptions\OAuthException invalid client/redirect/scope/PKCE - * @throws ServiceException when the OAuth2 module is not loaded for this route - */ - public function issueCode(string $userId, array $oauthParams): array - { - if ($this->oauthFlow === null) { - throw new ServiceException( - 'auth.mobile.oauth_unavailable', - layer: 'service.auth.mobile', - context: ['hint' => 'The oauth.server module must be required by this route.'], - ); - } - - $issued = $this->oauthFlow->issueCodeFor($oauthParams, $userId); - - return ['code' => $issued['code'], 'state' => $issued['state']]; - } - - // ── Registration (old register→code flow) ─────────────────────────────────── - - /** - * Create the account and return the fresh UserDTO. Auto-verifies the email - * (old mobile activate-on-register behaviour) unless disabled — the - * plaintext verification token never leaves the server either way. - */ - public function register(RegisterUserDTO $dto): UserDTO - { - $verificationToken = $this->users->registerPublic($dto); - - if ($this->autoVerify) { - // Old flow parity: mobile users are activated immediately so the - // code exchange isn't gated behind an inbox round-trip. Non-fatal — - // a failure just leaves the account pending verification. - try { - $this->users->verifyEmailByToken($verificationToken); - } catch (\Throwable) { - } - } - - $user = $this->users->findByIdentifier($dto->email->value()); - if ($user === null) { - throw new ServiceException('auth.mobile.register.lookup_failed', layer: 'service.auth.mobile'); - } - - return $user; - } - - // ── Logout (JTI blocklist) ────────────────────────────────────────────────── - - /** - * Blocklist the presented access token's JTI for its remaining lifetime. - * The token was already cryptographically verified by the `auth` filter — - * this only READS the payload to find jti/exp; a malformed token is a no-op. - */ - public function revokeAccessToken(?string $bearer): void - { - if ($bearer === null || $bearer === '') { - return; - } - - $parts = explode('.', $bearer); - if (\count($parts) !== 3) { - return; - } - - $padded = strtr($parts[1], '-_', '+/') . str_repeat('=', (4 - \strlen($parts[1]) % 4) % 4); - $decoded = base64_decode($padded, strict: true); - $payload = $decoded !== false ? json_decode($decoded, true) : null; - - if (!\is_array($payload) || !\is_string($payload['jti'] ?? null)) { - return; - } - - $remaining = (int) ($payload['exp'] ?? 0) - time(); - if ($remaining > 0) { - $this->auth->revokeJwt($payload['jti'], $remaining); - } - } -} diff --git a/plugins/Auth/Application/Services/RefreshTokenService.php b/plugins/Auth/Application/Services/RefreshTokenService.php deleted file mode 100644 index 83fc03f..0000000 --- a/plugins/Auth/Application/Services/RefreshTokenService.php +++ /dev/null @@ -1,158 +0,0 @@ -expiry($this->refreshTtl); - - // A freshly-issued token is the ROOT of its own rotation family. - $this->transactional(fn () => - $this->tokens->store($tokenId, $tokenId, $userId, Token::hash($rawToken), $tenantId, $device, $ip, $expiresAt)); - - return new RefreshTokenIssued($tokenId, $rawToken, $expiresAt->format(\DateTimeInterface::RFC3339)); - } - - public function rotate(string $rawToken, ?string $ip = null): RefreshRotation - { - $record = $this->tokens->findByHash(Token::hash($rawToken)); - if ($record === null) { - throw InvalidRefreshTokenException::invalid(); - } - - // Reuse detection: a known-but-already-revoked token is a replay of a - // token that was rotated away (or stolen). Burn the whole family. The - // burn runs in its OWN transaction, committed BEFORE the throw — it must - // persist, never be rolled back with the failed rotation. - if ($record->revoked) { - $this->transactional(fn () => $this->tokens->revokeFamily($record->familyId)); - throw InvalidRefreshTokenException::reuseDetected(); - } - - $newRawToken = Token::random(); - $newTokenId = Token::ulid(); - $refreshExp = $this->expiry($this->refreshTtl); - - // One-time-use rotation is ATOMIC: the conditional revoke of the - // presented token and the insert of its replacement commit or fail - // together — a crash between them can no longer strand the user with - // no valid refresh token. Only the request that wins the conditional - // revoke may proceed; a concurrent rotation loses the race (0 rows). - $won = $this->transactional(function () use ($record, $newTokenId, $newRawToken, $refreshExp, $ip): bool { - if (!$this->tokens->revokeIfActive($record->tokenId)) { - return false; - } - - $this->tokens->store($newTokenId, $record->familyId, $record->userId, Token::hash($newRawToken), $record->tenantId, null, $ip, $refreshExp); - - return true; - }); - - // Lost the race — treat as reuse: burn the family (own committed tx). - if (!$won) { - $this->transactional(fn () => $this->tokens->revokeFamily($record->familyId)); - throw InvalidRefreshTokenException::reuseDetected(); - } - - $tenantId = $record->tenantId; - - // Mint the paired access token (tnt is a passthrough hint, not re-verified). - $accessToken = $this->auth->issueJwt( - $record->userId, - ['tnt' => $tenantId ?? '', 'roles' => []], - $this->accessTtl, - ); - - return new RefreshRotation( - accessToken: $accessToken, - expiresIn: $this->accessTtl, - refreshToken: $newRawToken, - refreshExpiresAt: $refreshExp->format(\DateTimeInterface::RFC3339), - tenantId: $tenantId ?? '', - ); - } - - public function revoke(string $rawToken): void - { - $record = $this->tokens->findActiveByHash(Token::hash($rawToken)); - if ($record === null) { - return; - } - $this->transactional(fn () => $this->tokens->revoke($record->tokenId)); - } - - public function revokeAllForUser(string $userId): int - { - return $this->transactional(fn (): int => $this->tokens->revokeAllForUser($userId)); - } - - private function expiry(int $ttlSeconds): \DateTimeImmutable - { - return (new \DateTimeImmutable())->add(new \DateInterval('PT' . max(60, $ttlSeconds) . 'S')); - } - - /** - * Bracket a unit of work in a transaction on the central auth connection. - * Nesting-aware (TransactionManager), and a straight pass-through when no - * manager was injected (unit tests with in-memory stores). - */ - private function transactional(callable $work): mixed - { - if ($this->transaction === null) { - return $work(); - } - - $this->transaction->begin(); - try { - $result = $work(); - $this->transaction->commit(); - - return $result; - } catch (\Throwable $e) { - $this->transaction->rollback(); - throw $e; - } - } -} diff --git a/plugins/Auth/Domain/Entities/PersonalAccessToken.php b/plugins/Auth/Domain/Entities/PersonalAccessToken.php deleted file mode 100644 index 3e4b0a9..0000000 --- a/plugins/Auth/Domain/Entities/PersonalAccessToken.php +++ /dev/null @@ -1,95 +0,0 @@ - */ - protected array $casts = [ - // Entity short-circuits casts on null, so plain datetime casts are safe - // for the nullable expiry/last-used columns. - 'expires_at' => 'datetime', - 'last_used_at' => 'datetime', - 'created_at' => 'datetime', - ]; - - /** The token hash never appears in dumps/serialization. */ - protected array $hidden = ['token_hash']; - - /** - * Mint a new token record. $tokenHash MUST already be the SHA-256 of the - * plaintext — this entity never sees the raw token. - * - * @param list $abilities Scope list; [] = no abilities granted. - * @param \DateTimeImmutable|null $expiresAt Absolute expiry; null = never expires. - */ - public static function issue( - string $id, - string $userId, - string $name, - string $tokenHash, - array $abilities = [], - ?\DateTimeImmutable $expiresAt = null, - ): self { - $t = (new self())->forceFill([ - 'id' => $id, - 'user_id' => $userId, - 'name' => $name, - 'token_hash' => $tokenHash, - 'abilities' => array_values($abilities), - 'expires_at' => $expiresAt, - 'last_used_at' => null, - 'created_at' => new \DateTimeImmutable(), - ]); - $t->syncOriginal(); - - return $t; - } - - public function id(): string { return $this->getString('id'); } - public function userId(): string { return $this->getString('user_id'); } - public function name(): string { return $this->getString('name'); } - public function tokenHash(): string { return $this->getString('token_hash'); } - - /** @return list */ - public function abilities(): array - { - return array_values(array_filter($this->getArray('abilities'), 'is_string')); - } - - public function expiresAt(): ?\DateTimeImmutable - { - return $this->getRawAttribute('expires_at') === null ? null : $this->getDate('expires_at'); - } - - /** Expired tokens are treated as absent so a stale credential never authenticates. */ - public function isExpired(?\DateTimeImmutable $now = null): bool - { - $expiresAt = $this->expiresAt(); - - return $expiresAt !== null && $expiresAt <= ($now ?? new \DateTimeImmutable()); - } - - /** Persistence-shaped abilities column: a JSON array, or null when empty. */ - public function abilitiesColumn(): ?string - { - $abilities = $this->abilities(); - - return $abilities === [] ? null : json_encode($abilities); - } -} diff --git a/plugins/Auth/Domain/Entities/RefreshTokenRecord.php b/plugins/Auth/Domain/Entities/RefreshTokenRecord.php deleted file mode 100644 index f0fd400..0000000 --- a/plugins/Auth/Domain/Entities/RefreshTokenRecord.php +++ /dev/null @@ -1,58 +0,0 @@ -forceFill([ - 'tokenId' => $tokenId, - 'userId' => $userId, - 'tenantId' => ($tenantId === null || $tenantId === '') ? null : $tenantId, - 'familyId' => $familyId !== '' ? $familyId : $tokenId, - 'revoked' => $revoked, - ]); - $r->syncOriginal(); - - return $r; - } - - /** @param array $row */ - public static function fromRow(array $row): self - { - $tenant = isset($row['tenant_id']) ? (string) $row['tenant_id'] : null; - $family = isset($row['family_id']) ? (string) $row['family_id'] : ''; - - $r = (new self())->forceFill([ - 'tokenId' => (string) $row['token_id'], - 'userId' => (string) $row['user_id'], - 'tenantId' => ($tenant === null || $tenant === '') ? null : $tenant, - 'familyId' => $family !== '' ? $family : (string) $row['token_id'], - 'revoked' => \array_key_exists('revoked_at', $row) && $row['revoked_at'] !== null, - ]); - $r->syncOriginal(); - - return $r; - } -} diff --git a/plugins/Auth/Domain/Exceptions/AuthenticationException.php b/plugins/Auth/Domain/Exceptions/AuthenticationException.php deleted file mode 100644 index 38ab4df..0000000 --- a/plugins/Auth/Domain/Exceptions/AuthenticationException.php +++ /dev/null @@ -1,39 +0,0 @@ - $guards guards that failed to authenticate */ - public function __construct( - string $message = 'Unauthenticated.', - public readonly array $guards = [], - public readonly ?string $redirectTo = null, - ?\Throwable $previous = null, - ) { - parent::__construct($message, layer: 'auth.authentication', context: ['guards' => $guards], code: 401, previous: $previous); - } - - /** @return list */ - public function guards(): array - { - return $this->guards; - } - - public function redirectTo(): ?string - { - return $this->redirectTo; - } -} diff --git a/plugins/Auth/Domain/Exceptions/AuthorizationException.php b/plugins/Auth/Domain/Exceptions/AuthorizationException.php deleted file mode 100644 index e629d1f..0000000 --- a/plugins/Auth/Domain/Exceptions/AuthorizationException.php +++ /dev/null @@ -1,50 +0,0 @@ - $appCode], code: 403, previous: $previous); - } - - /** Override the HTTP status the pipeline should emit (e.g. 404 to mask). */ - public function withStatus(?int $status): static - { - $this->status = $status; - - return $this; - } - - /** Deny as 404 so the resource's existence is not revealed. */ - public function asNotFound(): static - { - return $this->withStatus(404); - } - - public function hasStatus(): bool - { - return $this->status !== null; - } - - public function status(): ?int - { - return $this->status; - } -} diff --git a/plugins/Auth/Domain/Exceptions/InvalidAuthTokenException.php b/plugins/Auth/Domain/Exceptions/InvalidAuthTokenException.php deleted file mode 100644 index cd67e2a..0000000 --- a/plugins/Auth/Domain/Exceptions/InvalidAuthTokenException.php +++ /dev/null @@ -1,27 +0,0 @@ - */ - private array $scopes; - - /** @param list|string $scopes one or more required scope names */ - public function __construct(array|string $scopes = [], string $message = 'Invalid scope(s) provided.') - { - $this->scopes = is_array($scopes) ? array_values($scopes) : [$scopes]; - - parent::__construct($message); - } - - /** @return list */ - public function scopes(): array - { - return $this->scopes; - } -} diff --git a/plugins/Auth/Domain/ValueObjects/Recaller.php b/plugins/Auth/Domain/ValueObjects/Recaller.php deleted file mode 100644 index 17870c7..0000000 --- a/plugins/Auth/Domain/ValueObjects/Recaller.php +++ /dev/null @@ -1,54 +0,0 @@ - segments from a single explode */ - private array $parts; - - public function __construct(private string $value) - { - $this->parts = explode('|', $value, 2); - } - - /** Compose the raw cookie value from its two segments. */ - public static function make(string $userId, string $token): self - { - return new self($userId . '|' . $token); - } - - public function id(): string - { - return $this->parts[0] ?? ''; - } - - public function token(): string - { - return $this->parts[1] ?? ''; - } - - /** Both segments present and non-blank, and the delimiter was actually there. */ - public function valid(): bool - { - return str_contains($this->value, '|') - && count($this->parts) === 2 - && trim($this->parts[0]) !== '' - && trim($this->parts[1]) !== ''; - } - - public function value(): string - { - return $this->value; - } -} diff --git a/plugins/Auth/Infrastructure/Auth/Drivers/JwtDriver.php b/plugins/Auth/Infrastructure/Auth/Drivers/JwtDriver.php deleted file mode 100644 index 2c9ebf4..0000000 --- a/plugins/Auth/Infrastructure/Auth/Drivers/JwtDriver.php +++ /dev/null @@ -1,26 +0,0 @@ -resolveVerdict($request, $context, ['jwt']); - } -} diff --git a/plugins/Auth/Infrastructure/Auth/Drivers/RequestDriver.php b/plugins/Auth/Infrastructure/Auth/Drivers/RequestDriver.php deleted file mode 100644 index 5172efb..0000000 --- a/plugins/Auth/Infrastructure/Auth/Drivers/RequestDriver.php +++ /dev/null @@ -1,31 +0,0 @@ -resolveVerdict($request, $context, ['jwt', 'api_key', 'session']); - } -} diff --git a/plugins/Auth/Infrastructure/Auth/Drivers/ResolvesFromVerdict.php b/plugins/Auth/Infrastructure/Auth/Drivers/ResolvesFromVerdict.php deleted file mode 100644 index dde99ba..0000000 --- a/plugins/Auth/Infrastructure/Auth/Drivers/ResolvesFromVerdict.php +++ /dev/null @@ -1,41 +0,0 @@ -identity(); - if ($identity === null || $identity->isGuest() || !in_array($identity->tokenType, $accept, true)) { - return null; - } - - $user = $context->provider->retrieveById($identity->userId); - if (!$user instanceof AuthUserProxy) { - return $user; - } - - return $user->withSecurity( - $identity->roles, - $identity->permissions, - $identity->tenantId, - $identity->tokenType, - ); - } -} diff --git a/plugins/Auth/Infrastructure/Auth/Drivers/SessionDriver.php b/plugins/Auth/Infrastructure/Auth/Drivers/SessionDriver.php deleted file mode 100644 index d824492..0000000 --- a/plugins/Auth/Infrastructure/Auth/Drivers/SessionDriver.php +++ /dev/null @@ -1,58 +0,0 @@ -session; - if ($session === null) { - return null; - } - - $userId = (string) $session->get(AuthService::SESSION_USER, ''); - if ($userId === '') { - return null; - } - - - $user = $context->provider->retrieveById($userId); - if (!$user instanceof AuthUserProxy) { - return $user; - } - - // Overlay the session-stored security context onto the base proxy. - return $user->withSecurity( - $this->stringList($session->get(AuthService::SESSION_ROLES, [])), - $this->stringList($session->get(AuthService::SESSION_PERMISSIONS, [])), - (string) $session->get(AuthService::SESSION_TENANT, ''), - 'session', - ); - } - - /** @return list */ - private function stringList(mixed $value): array - { - return \is_array($value) ? array_values(array_filter($value, 'is_string')) : []; - } -} diff --git a/plugins/Auth/Infrastructure/Auth/Drivers/TokenDriver.php b/plugins/Auth/Infrastructure/Auth/Drivers/TokenDriver.php deleted file mode 100644 index 894f900..0000000 --- a/plugins/Auth/Infrastructure/Auth/Drivers/TokenDriver.php +++ /dev/null @@ -1,26 +0,0 @@ -resolveVerdict($request, $context, ['api_key']); - } -} diff --git a/plugins/Auth/Infrastructure/Cli/PruneAccessTokensCommand.php b/plugins/Auth/Infrastructure/Cli/PruneAccessTokensCommand.php deleted file mode 100644 index d432a79..0000000 --- a/plugins/Auth/Infrastructure/Cli/PruneAccessTokensCommand.php +++ /dev/null @@ -1,70 +0,0 @@ -name = 'auth:tokens:prune'; - $this->description = 'Delete expired personal access tokens from the control-plane table'; - - $this->addOption('dry', '', 'Report the count without deleting anything'); - $this->addOption('watch', '', 'Run forever, pruning every N seconds (supervised loop)', acceptsValue: true, default: ''); - } - - protected function handle(): int - { - $watch = (int) $this->option('watch'); - if ($watch <= 0) { - return $this->prune(); - } - - // Supervised loop for environments without cron (containers/systemd). - // A process supervisor restarts it if it exits; min 60s guards against a - // hot loop. - $interval = max(60, $watch); - $this->info("Watching: pruning expired access tokens every {$interval}s. Ctrl-C to stop."); - while (true) { - $this->prune(); - sleep($interval); - } - } - - private function prune(): int - { - if ($this->hasOption('dry')) { - $count = $this->tokens->countExpired(); - $this->info("{$count} expired access token(s) would be pruned (dry run — nothing deleted)."); - - return self::SUCCESS; - } - - $deleted = $this->tokens->deleteExpired(); - $this->info("Pruned {$deleted} expired access token(s)."); - - return self::SUCCESS; - } -} diff --git a/plugins/Auth/Infrastructure/Http/Controllers/AuthTokenController.php b/plugins/Auth/Infrastructure/Http/Controllers/AuthTokenController.php deleted file mode 100644 index 3b27262..0000000 --- a/plugins/Auth/Infrastructure/Http/Controllers/AuthTokenController.php +++ /dev/null @@ -1,53 +0,0 @@ -resolveRequest(); - $token = trim((string) $request->input('token')); - if ($token === '') { - return $this->unprocessable(['token' => 'A refresh token is required.']); - } - - try { - $rotation = $this->refreshTokens->rotate($token, $request->ip()); - } catch (InvalidRefreshTokenException $e) { - return Response::unauthorized($e->getMessage()); - } - - return $this->ok($rotation->toArray()); - } - - /** POST /auth/refresh/logout { "token": "…" } — revoke a single refresh token. */ - public function logout(): Response - { - $request = $this->resolveRequest(); - $token = trim((string) $request->input('token')); - if ($token !== '') { - $this->refreshTokens->revoke($token); - } - - return $this->noContent(); - } -} diff --git a/plugins/Auth/Infrastructure/Http/Controllers/MobileAuthController.php b/plugins/Auth/Infrastructure/Http/Controllers/MobileAuthController.php deleted file mode 100644 index eb86a30..0000000 --- a/plugins/Auth/Infrastructure/Http/Controllers/MobileAuthController.php +++ /dev/null @@ -1,139 +0,0 @@ -authManager()->issueTokenPair()` / - * ->issueToken()), the exact parity of the old `AuthManager::issueToken('mobile', - * …)`. Nothing here reaches into AuthService/RefreshTokenService directly. - * - * POST /auth/mobile/login { email|identifier, password [, PKCE params] } - * PKCE (client_id set) → 200 { code, state } - * legacy (no client_id) → 200 { user, tokens } - * POST /auth/mobile/register registration fields + optional PKCE params - * → 201 { code, state } | 201 { user, tokens } - * POST /auth/mobile/logout (Bearer) → 200 {} — JTI blocklisted. - * - * Refresh rotation stays at POST /auth/refresh (AuthTokenController). - */ -final class MobileAuthController extends ApiController -{ - use InteractsWithAuthManager; - - public function __construct( - private readonly UserServiceContract $users, - private readonly MobileAuthService $mobile, - ) { - } - - public function login(): Response - { - $request = $this->resolveRequest(); - $identifier = trim((string) ($request->input('identifier') ?? $request->input('email'))); - $password = (string) $request->input('password'); - - if ($identifier === '' || $password === '') { - return $this->unprocessable([ - 'identifier' => $identifier === '' ? 'An email or username is required.' : '', - 'password' => $password === '' ? 'A password is required.' : '', - ]); - } - - $user = $this->users->verifyCredentials($identifier, $password); - if ($user === null) { - return Response::unauthorized('Invalid email/username or password.'); - } - - if ($this->wantsPkce($request)) { - return $this->issueCodeResponse($request, $user->id); - } - - return $this->ok([ - 'user' => $user->toArray(), - 'tokens' => $this->authManager()->issueTokenPair($user->id, device: $request->header('User-Agent'), ip: $request->ip()), - ]); - } - - public function register(): Response - { - $request = $this->resolveRequest(); - - // Mobile clients register with email only — synthesize the internal - // username from the email local-part (old flow) when none is sent. - if (trim((string) $request->input('username', '')) === '') { - $request = $request->merge(['username' => $this->usernameFromEmail((string) $request->input('email', ''))]); - } - - $user = $this->mobile->register(RegisterUserDTO::fromRequest($request)); // 422 on bad input - - if ($this->wantsPkce($request)) { - return $this->issueCodeResponse($request, $user->id, status: 201); - } - - return $this->created([ - 'user' => $user->toArray(), - 'tokens' => $this->authManager()->issueTokenPair($user->id, device: $request->header('User-Agent'), ip: $request->ip()), - ]); - } - - public function logout(): Response - { - $this->mobile->revokeAccessToken($this->resolveRequest()->bearerToken()); - - return $this->ok([]); - } - - // ── Internals ─────────────────────────────────────────────────────────────── - - private function wantsPkce(Request $request): bool - { - return trim((string) $request->input('client_id', '')) !== ''; - } - - private function issueCodeResponse(Request $request, string $userId, int $status = 200): Response - { - try { - $issued = $this->mobile->issueCode($userId, [ - 'client_id' => (string) $request->input('client_id', ''), - 'redirect_uri' => (string) $request->input('redirect_uri', ''), - 'scope' => (string) $request->input('scope', ''), - 'state' => (string) $request->input('state', ''), - 'code_challenge' => (string) $request->input('code_challenge', ''), - 'code_challenge_method' => (string) $request->input('code_challenge_method', ''), - ]); - } catch (OAuthException $e) { - return Response::json( - ['error' => ['code' => $e->error, 'message' => $e->getMessage()]], - $e->status, - ); - } - - return Response::json($issued, $status); - } - - /** Old flow: email local-part + 4 random hex chars — internal, never exposed. */ - private function usernameFromEmail(string $email): string - { - $local = (string) preg_replace('/[^A-Za-z0-9._-]/', '', explode('@', $email)[0] ?? ''); - if (\strlen($local) < 2) { - $local = 'user'; - } - - return strtolower(substr($local, 0, 42)) . '_' . substr(bin2hex(random_bytes(2)), 0, 4); - } -} diff --git a/plugins/Auth/Infrastructure/Http/Controllers/PasswordResetController.php b/plugins/Auth/Infrastructure/Http/Controllers/PasswordResetController.php deleted file mode 100644 index 30863c0..0000000 --- a/plugins/Auth/Infrastructure/Http/Controllers/PasswordResetController.php +++ /dev/null @@ -1,176 +0,0 @@ -resolveRequest()->input('email'))); - if ($email === '') { - return $this->unprocessable(['email' => 'An email address is required.']); - } - - $sent = $this->broker->sendOtp($email); - - if ($sent !== null && $this->mail !== null) { - try { - $this->mail->send( - $sent['email'], - 'Your password reset code', - 'auth::password-otp', - ['otp' => $sent['otp'], 'expiresMinutes' => 10], - ); - } catch (\Throwable) { - // Non-fatal — never leak delivery problems to the caller. - } - } - - // Always 200 — never reveals whether an account exists. - return $this->ok(['message' => self::GENERIC_FORGOT_MESSAGE]); - } - - public function verifyOtp(): Response - { - $request = $this->resolveRequest(); - $email = mb_strtolower(trim((string) $request->input('email'))); - $otp = trim((string) $request->input('otp')); - - if ($email === '' || !preg_match('/^\d{6}$/', $otp)) { - return $this->unprocessable([ - 'email' => $email === '' ? 'An email address is required.' : '', - 'otp' => 'The code must be exactly 6 digits.', - ]); - } - - $token = $this->broker->verifyOtp($email, $otp); - if ($token === null) { - return Response::json([ - 'error' => [ - 'code' => 'auth.password.otp_invalid', - 'message' => "That code doesn't match or has expired. Codes are valid for 10 minutes — request a fresh one.", - ] - ], 400); - } - - return $this->ok(['resetToken' => $token]); - } - - /** Hashing cost is attacker-controlled by length — cap it well below any DoS. */ - private const MAX_PASSWORD_BYTES = 4096; - - public function reset(): Response - { - $request = $this->resolveRequest(); - $email = mb_strtolower(trim((string) $request->input('email'))); - $token = trim((string) $request->input('token')); - $password = (string) $request->input('password'); - $confirm = $request->input('password_confirmation'); - - $errors = []; - - if ($email === '') { - $errors['email'] = 'An email address is required.'; - } - if ($token === '') { - $errors['token'] = 'Your reset session is missing — request a new code.'; - } - - if (\strlen($password) < 8) { - $errors['password'] = 'Password must be at least 8 characters.'; - } elseif (\strlen($password) > self::MAX_PASSWORD_BYTES) { - // Unbounded input would let anyone burn CPU in the hasher at will. - $errors['password'] = 'Password must be at most ' . self::MAX_PASSWORD_BYTES . ' characters.'; - } elseif (str_contains($password, "\0")) { - // password_hash() throws on a NUL byte — reject it as input, not a 500. - $errors['password'] = 'Password contains an invalid character.'; - } elseif (mb_strtolower($password) === $email && $email !== '') { - $errors['password'] = 'Your password cannot be your email address.'; - } - - // Confirmation is enforced whenever the client sends it, so the browser - // form's check cannot be skipped by posting the endpoint directly. - if (!isset($errors['password']) && $confirm !== null && !hash_equals($password, (string) $confirm)) { - $errors['password_confirmation'] = 'The two passwords do not match.'; - } - - if ($errors !== []) { - return $this->unprocessable($errors); - } - - if ($this->broker->reset($email, $token, $password) !== PasswordBroker::PASSWORD_RESET) { - return Response::json(['error' => [ - 'code' => 'auth.password.reset_invalid', - 'message' => 'This reset session has already been used or has expired. Please start again.', - ]], 400); - } - - $this->notifyPasswordChanged($email, $request); - - return $this->ok(['message' => 'Your password has been updated. You can now sign in.']); - } - - /** - * Tell the account owner their password just changed. - * - * This is the account-takeover tripwire: when the reset was NOT them, this - * mail is the only signal they get — so it goes out on every successful - * reset, and carries no token and no login link. - * - * Best-effort: the password is already changed and the sessions already - * swept by the time we get here, so a mail failure must never turn a - * completed reset into an error the user would retry. - */ - private function notifyPasswordChanged(string $email, Request $request): void - { - if ($this->mail === null) { - return; - } - - try { - $this->mail->send( - $email, - 'Your password was changed', - 'auth::password-changed', - [ - 'email' => $email, - 'changedAt' => gmdate('D, d M Y H:i') . ' UTC', - // Kernel surface, not Symfony's — ip() already resolves the - // client address (honouring trusted proxies). - 'ip' => (string) ($request->ip() ?? ''), - ], - ); - } catch (\Throwable) { - // Never surface delivery problems on a reset that already succeeded. - } - } -} diff --git a/plugins/Auth/Infrastructure/Http/Controllers/PersonalAccessTokenController.php b/plugins/Auth/Infrastructure/Http/Controllers/PersonalAccessTokenController.php deleted file mode 100644 index 5f39f35..0000000 --- a/plugins/Auth/Infrastructure/Http/Controllers/PersonalAccessTokenController.php +++ /dev/null @@ -1,87 +0,0 @@ -identity(); - if ($identity->isGuest()) { - return Response::unauthorized('Authentication required.'); - } - - $tokens = array_map( - static fn($t) => $t->toArray(), - $this->auth->tokensFor($identity->userId), - ); - - return $this->ok(['tokens' => $tokens]); - } - - public function store(): Response - { - $identity = $this->identity(); - if ($identity->isGuest()) { - return Response::unauthorized('Authentication required.'); - } - - $request = $this->request; - $name = trim((string) $request?->input('name', 'default')) ?: 'default'; - $abilities = $request?->input('abilities', []); - $abilities = is_array($abilities) ? array_values(array_filter($abilities, 'is_string')) : []; - $ttl = $request?->input('ttl'); - $ttl = is_numeric($ttl) ? (int) $ttl : null; - - $result = $this->auth->createPersonalAccessToken($identity->userId, $name, $abilities, $ttl); - - // Plaintext token is returned exactly once. - return $this->created($result); - } - - public function destroy(string $id): Response - { - $identity = $this->identity(); - if ($identity->isGuest()) { - return Response::unauthorized('Authentication required.'); - } - - // Ownership check — only revoke a token that belongs to the caller. - $owns = false; - foreach ($this->auth->tokensFor($identity->userId) as $token) { - if ($token->id === $id) { - $owns = true; - break; - } - } - if (!$owns) { - return Response::notFound(); - } - - $this->auth->revokePersonalAccessToken($id); - - return $this->noContent(); - } -} diff --git a/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php b/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php deleted file mode 100644 index 07eec9e..0000000 --- a/plugins/Auth/Infrastructure/Http/Controllers/SessionAuthController.php +++ /dev/null @@ -1,219 +0,0 @@ -auth('web')` — exactly - * the old `$auth->guard('web')->attempt()/logout()` ergonomic. The 'web' guard's - * driver owns credential verification, the session write, remember-me, and the - * device-session registry; this controller only translates request → guard call - * → Response. Incoming-token verification (for token/JWT callers) still happens - * in the SecurityGateway before modules load — that is the one thing the guard - * cannot own under GDA. - * - * POST /auth/login { identifier|email, password, remember? } - * POST /auth/logout → 204 - * GET /auth/me → current identity - * GET /auth/sessions → active device sessions - * DELETE /auth/sessions/{id} → revoke one device - * POST /auth/logout-other-devices { password } → revoke all OTHER devices - * - * CSRF: session/cookie endpoints (NOT under /api) are guarded by the kernel's - * CsrfTokenLayer — send the token via `X-CSRF-Token` (AJAX) or `_csrf_token`. - */ -final class SessionAuthController extends ApiController -{ - use InteractsWithAuthManager; - - public function __construct( - // Only the device-management endpoints (list / revoke-by-id) touch the - // registry directly; the login/logout flow goes through the guard. - private readonly ?DeviceSessionService $devices = null, - ) { - } - - public function login(): Response - { - $request = $this->resolveRequest(); - $identifier = trim((string) ($request->input('identifier') ?? $request->input('email'))); - $password = (string) $request->input('password'); - - if ($identifier === '' || $password === '') { - return $this->unprocessable([ - 'identifier' => $identifier === '' ? 'An email or username is required.' : '', - 'password' => $password === '' ? 'A password is required.' : '', - ]); - } - - $guard = $this->auth('web'); - - - // The 'web' guard's driver verifies credentials, opens the session, - // binds the device fingerprint + auth_sessions row, and (when asked) - // queues the remember-me recaller — all internally. - if (!$guard->attempt($this->credentials($identifier, $password), $request->boolean('remember'))) { - // Uniform message — never reveals whether the account exists or is locked. - return Response::unauthorized('Invalid credentials.'); - } - - // Where to send the user after sign-in, first match wins: - // 1. an explicit `redirectTo` on the login request itself (query or - // body — e.g. /auth/login?redirectTo=/billing), - // 2. the previous page recorded by the Session plugin's - // StartSessionStage — PULLED (one-time) either way, so the - // fulfilled intent never goes stale, - // 3. '/'. - // Both candidates pass the same open-redirect guard. - $previous = $this->sessionPull(StartSessionStage::PREVIOUS_URL); - $redirect = $this->safeRedirect($request->input('redirectTo')) - ?? $this->safeRedirect($previous) - ?? '/'; - - // Browser form POST → real redirect; AJAX/SPA callers get the target in - // the payload and navigate client-side. - if (!$request->expectsJson()) { - return Response::redirect($redirect); - } - - return $this->ok(['user' => $this->shape($guard->user()), 'redirectTo' => $redirect]); - } - - /** - * Validate a redirect candidate into a safe INTERNAL target, or null when - * it is unusable. Accepts only a relative path ('/…'); rejects - * protocol-relative ('//…'), backslash tricks and absolute URLs — the - * open-redirect guard for both the request param and the session value. - */ - private function safeRedirect(mixed $candidate): ?string - { - if (!is_string($candidate) || $candidate === '' || $candidate[0] !== '/' - || str_starts_with($candidate, '//') || str_starts_with($candidate, '/\\')) { - return null; - } - - return $candidate; - } - - public function logout(): Response - { - // Guard tears down the session, revokes this device's registry row, and - // clears the remember-me token + cookie. - $this->auth('web')->logout(); - - return $this->noContent(); - } - - public function me(): Response - { - // Reflects HOWEVER the request is authenticated (session OR a verified - // token attached by the SecurityGateway), so read the request Identity. - $identity = $this->identity(); - if ($identity->isGuest()) { - return Response::unauthorized('Not authenticated.'); - } - - return $this->ok([ - 'userId' => $identity->userId, - 'username' => $identity->username, - 'email' => $identity->email, - 'fullName' => $identity->fullName, - 'tenantId' => $identity->tenantId, - 'roles' => $identity->roles, - 'permissions' => $identity->permissions, - 'via' => $identity->tokenType, - ]); - } - - // ── Device sessions ("see & sign out my devices") ─────────────────────────── - - public function sessions(): Response - { - if ($this->devices === null) { - return $this->ok(['sessions' => []]); - } - - return $this->ok([ - 'sessions' => $this->devices->listDevices($this->identity()->userId, $this->guardSession()), - ]); - } - - public function revokeSession(string $id): Response - { - if ($this->devices === null || !$this->devices->revokeById($this->identity()->userId, $id)) { - return $this->notFound('No such session.'); - } - - return $this->noContent(); - } - - public function logoutOtherDevices(): Response - { - $password = (string) $this->resolveRequest()->input('password'); - if ($password === '') { - return $this->unprocessable(['password' => 'Your current password is required.']); - } - - // Guard re-verifies the password, revokes every OTHER device's session, - // and rotates the remember token (reissuing this device's cookie). - $user = $this->auth('web')->logoutOtherDevices($password); - if ($user === null) { - return Response::unauthorized('Password confirmation failed.'); - } - - return $this->ok(['message' => 'Signed out of all other devices.']); - } - - // ── Internals ─────────────────────────────────────────────────────────────── - - /** - * Credential map: the identifier is offered under every lookup field the - * ModelUserProvider tries (identifier/email/username), so a single input - * works whether the user typed an email or a username. - * - * @return array - */ - private function credentials(string $identifier, string $password): array - { - return [ - 'identifier' => $identifier, - 'email' => $identifier, - 'username' => $identifier, - 'password' => $password, - ]; - } - - /** @return array */ - private function shape(?Authenticatable $user): array - { - if ($user === null) { - return []; - } - - return [ - 'id' => $user->getAuthIdentifier(), - 'email' => $user->getEmail(), - 'username' => $user->getUsername(), - ]; - } - - /** The active session store, for flagging the caller's own device in listDevices(). */ - private function guardSession(): ?\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort - { - $container = $this->resolveRequest()->container(); - - return $container !== null && $container->has(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort::class) - ? $container->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\SessionPort::class) - : null; - } -} diff --git a/plugins/Auth/Infrastructure/Http/Controllers/TransientTokenController.php b/plugins/Auth/Infrastructure/Http/Controllers/TransientTokenController.php deleted file mode 100644 index b9f2b52..0000000 --- a/plugins/Auth/Infrastructure/Http/Controllers/TransientTokenController.php +++ /dev/null @@ -1,61 +0,0 @@ -identity(); - if ($identity->isGuest() || $identity->tokenType !== 'session') { - // Only a real session may mint a transient token (never a Bearer caller). - return Response::unauthorized('A web session is required.'); - } - - $token = $this->auth->issueJwt( - $identity->userId, - [ - 'roles' => $identity->roles, - 'permissions' => $identity->permissions, - 'tnt' => $identity->tenantId, - // Carry the session's display identity onto the minted JWT so - // the SPA's Bearer requests keep username/email/fullName. - 'preferred_username' => $identity->username, - 'email' => $identity->email, - 'name' => $identity->fullName, - ], - self::TTL_SECONDS, - ); - - return $this->ok([ - 'access_token' => $token, - 'token_type' => 'Bearer', - 'expires_in' => self::TTL_SECONDS, - ]); - } -} diff --git a/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php b/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php deleted file mode 100644 index 69c2f3b..0000000 --- a/plugins/Auth/Infrastructure/Http/Stages/SessionAuthStage.php +++ /dev/null @@ -1,211 +0,0 @@ -identity(); - - if ($existing !== null && !$existing->isGuest()) { - return $next($request); - } - - $container = $request->container(); - - if ($container === null || !$container->has(SessionPort::class)) { - return $next($request); - } - - $session = $container->make(SessionPort::class); - - if (!$session instanceof SessionPort) { - return $next($request); - } - - $userId = (string) $session->get(AuthService::SESSION_USER, ''); - if ($userId === '') { - // No live session — try to resurrect one from a "remember me" cookie. - $resurrected = $this->fromRecaller($request, $container, $session); - if ($resurrected !== null) { - $request = $resurrected; - } - - return $next($request); - } - - - - // Fingerprint + device-session validation (old __DEV__ semantics): a - // request that can't reproduce the login fingerprint, or whose server- - // side session row was revoked/expired, loses the session outright. - if ($container->has(DeviceSessionService::class)) { - $devices = $container->make(DeviceSessionService::class); - if ($devices instanceof DeviceSessionService && !$devices->verify($session, $request)) { - $devices->teardown($session); - $session->invalidate(); - - return $next($request); // continue as guest - } - } - - $identity = new Identity( - userId: $userId, - tenantId: (string) $session->get(AuthService::SESSION_TENANT, ''), - roles: $this->stringList($session->get(AuthService::SESSION_ROLES, [])), - permissions: $this->stringList($session->get(AuthService::SESSION_PERMISSIONS, [])), - tokenType: 'session', - username: (string) $session->get(AuthService::SESSION_USERNAME, ''), - email: (string) $session->get(AuthService::SESSION_EMAIL, ''), - fullName: (string) $session->get(AuthService::SESSION_NAME, ''), - avatarUrl: (string) $session->get(AuthService::SESSION_AVATAR, ''), - ); - - return $next($this->attach($request, $container, $identity)); - } - - /** - * Attach a session Identity to BOTH the request and the request-scoped - * container. - * - * Why the container too: services inject Identity from the ModuleContainer, - * which OnDemandLoader binds at LoadStage from the PRE-auth (guest) request — - * that runs before this after.load stage. Setting it only on the request - * would authenticate route filters (the `auth` alias) but leave the service - * layer seeing a guest, so service-level permission checks would wrongly - * fail. Rebinding here is safe: no service resolves Identity until - * ExecuteStage, which runs after this stage. Token auth is unaffected — it - * attaches its Identity in the SecurityGateway, before LoadStage, so the - * container already holds the correct one. - */ - private function attach(Request $request, $container, Identity $identity): Request - { - $container->instance(Identity::class, $identity); - - return $request->withIdentity($identity); - } - - /** - * Validate a "remember me" recaller cookie and, on success, re-open a full - * session login. Returns the request carrying the rebuilt Identity, or null - * when there is no valid recaller (request left untouched, stays guest). - * - * Security: the token is matched by its stored hash, and on EVERY successful - * use it is rotated (a fresh token + cookie) so a stolen cookie is a - * single-use window — the old __DEV__ remember-me guarantee, GDA-native. - */ - private function fromRecaller(Request $request, $container, SessionPort $session): ?Request - { - if (!$container->has(CookieJar::class) - || !$container->has(UserServiceContract::class) - || !$container->has(AuthServiceContract::class)) { - return null; - } - - $cookies = $container->make(CookieJar::class); - $raw = $cookies instanceof CookieJar ? $cookies->read($request, self::RECALLER_COOKIE) : null; - if ($raw === null || $raw === '') { - return null; - } - - $recaller = new Recaller($raw); - if (!$recaller->valid()) { - return null; - } - - $users = $container->make(UserServiceContract::class); - $user = $users instanceof UserServiceContract ? $users->findByRememberToken($recaller->token()) : null; - - // The cookie's id must match the token owner — defence against a - // mismatched/forged pairing. - if ($user === null || $user->id !== $recaller->id()) { - return null; - } - - // Re-establish the stateful session (rotates the session id) and rotate - // the remember token + cookie so this recaller can't be replayed. - $auth = $container->make(AuthServiceContract::class); - if ($auth instanceof AuthServiceContract) { - $auth->startSession($session, $user->id, username: $user->username, email: $user->email, fullName: $user->fullName, avatarUrl: $user->avatarUrl, tenantId: $user->tenantId ?? '', roles: $user->roles, permissions: $user->permissions); - } else { - $session->put(AuthService::SESSION_USER, $user->id); - $session->put(AuthService::SESSION_USERNAME, $user->username); - $session->put(AuthService::SESSION_EMAIL, $user->email); - $session->put(AuthService::SESSION_AVATAR, $user->avatarUrl); - $session->put(AuthService::SESSION_NAME, $user->fullName); - $session->put(AuthService::SESSION_TENANT, $user->tenantId ?? ''); - $session->put(AuthService::SESSION_ROLES, $user->roles); - $session->put(AuthService::SESSION_PERMISSIONS, $user->permissions); - } - - $fresh = $users->cycleRememberToken($user->id); - $cookies->queue( - self::RECALLER_COOKIE, - Recaller::make($user->id, $fresh)->value(), - maxAge: self::RECALLER_TTL, - ); - - return $this->attach($request, $container, new Identity( - userId: $user->id, - tenantId: $user->tenantId ?? '', - roles: $user->roles, - permissions: $user->permissions, - tokenType: 'session', - username: $user->username, - email: $user->email, - fullName: $user->fullName, - avatarUrl: $user->avatarUrl, - )); - } - - /** @return list */ - private function stringList(mixed $value): array - { - if (!is_array($value)) { - return []; - } - - return array_values(array_filter($value, 'is_string')); - } -} diff --git a/plugins/Auth/Infrastructure/Persistence/DeviceSessionRepository.php b/plugins/Auth/Infrastructure/Persistence/DeviceSessionRepository.php deleted file mode 100644 index 0558080..0000000 --- a/plugins/Auth/Infrastructure/Persistence/DeviceSessionRepository.php +++ /dev/null @@ -1,189 +0,0 @@ -db->execute( - "INSERT INTO {$this->table} - (session_id, user_id, token_hash, fingerprint, ip, user_agent, last_seen_at, expires_at, created_at) - VALUES (:session_id, :user_id, :token_hash, :fingerprint, :ip, :user_agent, :last_seen_at, :expires_at, :created_at)", - [ - 'session_id' => $sessionId, - 'user_id' => $userId, - 'token_hash' => $tokenHash, - 'fingerprint' => $fingerprint, - 'ip' => $ip !== null ? mb_substr($ip, 0, 45) : null, - 'user_agent' => $userAgent !== null ? mb_substr($userAgent, 0, 191) : null, - 'last_seen_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - 'expires_at' => $expiresAt->format('Y-m-d H:i:s'), - 'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to open device session', layer: 'repository.auth', previous: $e); - } - } - - /** - * Look up an UNREVOKED session by its token hash. Expiry is enforced in PHP - * by the caller (driver-portable — no NOW() dialect branching). - * - * @return array{session_id:string,user_id:string,fingerprint:?string,last_seen_at:?string,expires_at:string}|null - */ - public function findActiveByHash(string $tokenHash): ?array - { - try { - return $this->db->queryOne( - "SELECT session_id, user_id, fingerprint, last_seen_at, expires_at - FROM {$this->table} WHERE token_hash = :hash AND revoked_at IS NULL", - ['hash' => $tokenHash] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to look up device session', layer: 'repository.auth', previous: $e); - } - } - - /** - * Stamp last-seen and (for rolling refresh) push the expiry forward. - * Best-effort — observability + sliding lifetime, not an auth gate. - */ - public function touch(string $sessionId, ?\DateTimeImmutable $newExpiresAt = null): void - { - $params = [ - 'now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - 'id' => $sessionId, - ]; - $set = 'last_seen_at = :now'; - - if ($newExpiresAt !== null) { - $set .= ', expires_at = :expires_at'; - $params['expires_at'] = $newExpiresAt->format('Y-m-d H:i:s'); - } - - try { - $this->db->execute("UPDATE {$this->table} SET {$set} WHERE session_id = :id", $params); - } catch (\PDOException) { - // Non-fatal. - } - } - - public function revokeByHash(string $tokenHash): void - { - try { - $this->db->execute( - "UPDATE {$this->table} SET revoked_at = :now WHERE token_hash = :hash AND revoked_at IS NULL", - ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'hash' => $tokenHash] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to revoke device session', layer: 'repository.auth', previous: $e); - } - } - - /** Revoke one of a user's sessions by its public id. True when a row changed. */ - public function revokeForUser(string $userId, string $sessionId): bool - { - try { - return $this->db->execute( - "UPDATE {$this->table} SET revoked_at = :now - WHERE user_id = :user_id AND session_id = :id AND revoked_at IS NULL", - [ - 'now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - 'user_id' => $userId, - 'id' => $sessionId, - ] - ) > 0; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to revoke device session', layer: 'repository.auth', previous: $e); - } - } - - /** Revoke every active session for a user, optionally sparing one (the current device). */ - public function revokeAllForUser(string $userId, ?string $exceptSessionId = null): int - { - $sql = "UPDATE {$this->table} SET revoked_at = :now WHERE user_id = :user_id AND revoked_at IS NULL"; - $params = [ - 'now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - 'user_id' => $userId, - ]; - - if ($exceptSessionId !== null) { - $sql .= ' AND session_id <> :except'; - $params['except'] = $exceptSessionId; - } - - try { - return $this->db->execute($sql, $params); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to revoke device sessions', layer: 'repository.auth', previous: $e); - } - } - - /** - * All active (unrevoked, unexpired) sessions for a user, newest first. - * Never returns the token hash. - * - * @return list - */ - public function listActiveForUser(string $userId): array - { - try { - $rows = $this->db->query( - "SELECT session_id, ip, user_agent, last_seen_at, created_at, expires_at - FROM {$this->table} - WHERE user_id = :user_id AND revoked_at IS NULL AND expires_at > :cutoff - ORDER BY created_at DESC", - [ - 'user_id' => $userId, - 'cutoff' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list device sessions', layer: 'repository.auth', previous: $e); - } - - return array_values($rows); - } - - /** Delete expired/revoked rows older than the cutoff (maintenance). */ - public function deleteStale(?\DateTimeImmutable $now = null): int - { - $cutoff = ($now ?? new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - return $this->db->execute( - "DELETE FROM {$this->table} WHERE expires_at <= :cutoff OR revoked_at IS NOT NULL", - ['cutoff' => $cutoff] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to prune device sessions', layer: 'repository.auth', previous: $e); - } - } -} diff --git a/plugins/Auth/Infrastructure/Persistence/PersonalAccessTokenRepository.php b/plugins/Auth/Infrastructure/Persistence/PersonalAccessTokenRepository.php deleted file mode 100644 index ac5cbef..0000000 --- a/plugins/Auth/Infrastructure/Persistence/PersonalAccessTokenRepository.php +++ /dev/null @@ -1,186 +0,0 @@ - $abilities Scope list; null/[] = no abilities granted. - * @param \DateTimeImmutable|null $expiresAt Absolute expiry; null = never expires. - */ - public function store( - string $id, - string $userId, - string $name, - string $tokenHash, - array $abilities = [], - ?\DateTimeImmutable $expiresAt = null, - ): void { - $token = PersonalAccessToken::issue($id, $userId, $name, $tokenHash, $abilities, $expiresAt); - - try { - $this->db->execute( - "INSERT INTO {$this->table} (id, user_id, name, token_hash, abilities, expires_at, created_at) - VALUES (:id, :user_id, :name, :token_hash, :abilities, :expires_at, :created_at)", - [ - 'id' => $token->id(), - 'user_id' => $token->userId(), - 'name' => $token->name(), - 'token_hash' => $token->tokenHash(), - 'abilities' => $token->abilitiesColumn(), - 'expires_at' => $token->expiresAt()?->format('Y-m-d H:i:s'), - 'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to store access token', layer: 'repository.auth', previous: $e); - } - } - - /** - * Look up an UNEXPIRED token by its hash. Expired tokens are treated as - * absent so a stale credential can never authenticate. - * - * The owning user's display identity (username/email from the central - * `users` table) rides on the same query via a LEFT JOIN, so the security - * layer can build a full Identity without touching the DatabasePort itself. - * A missing user row degrades to '' — display data never gates auth. - * - * @return array{id:string,user_id:string,abilities:list,username:string,email:string}|null - */ - public function findByHash(string $tokenHash): ?array - { - try { - $row = $this->db->queryOne( - "SELECT t.id, t.user_id, t.abilities, t.expires_at, - u.username AS owner_username, u.email AS owner_email - FROM {$this->table} t - LEFT JOIN {$this->usersTable} u ON u.user_id = t.user_id - WHERE t.token_hash = :hash", - ['hash' => $tokenHash] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to look up access token', layer: 'repository.auth', previous: $e); - } - - if ($row === null) { - return null; - } - - $username = (string) ($row['owner_username'] ?? ''); - $email = (string) ($row['owner_email'] ?? ''); - unset($row['owner_username'], $row['owner_email']); - - $token = PersonalAccessToken::reconstitute($row); - - // Enforce expiry in PHP (driver-portable — no NOW() dialect branching). - if ($token->isExpired()) { - return null; - } - - return [ - 'id' => $token->id(), - 'user_id' => $token->userId(), - 'abilities' => $token->abilities(), - 'username' => $username, - 'email' => $email, - ]; - } - - /** - * List every token issued to a user (newest first), WITHOUT the hash. Feeds - * AuthServiceContract::tokensFor() — the GDA replacement for HasApiTokens. - * - * @return list - */ - public function findByUser(string $userId): array - { - try { - $rows = $this->db->query( - "SELECT id, name, abilities, expires_at, last_used_at, created_at - FROM {$this->table} WHERE user_id = :user_id ORDER BY created_at DESC", - ['user_id' => $userId] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list access tokens', layer: 'repository.auth', previous: $e); - } - - return array_values($rows); - } - - /** Stamp the token's last-use time (best-effort observability/anomaly detection). */ - public function touch(string $id): void - { - try { - $this->db->execute( - "UPDATE {$this->table} SET last_used_at = :now WHERE id = :id", - ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'id' => $id] - ); - } catch (\PDOException) { - // Non-fatal — last_used_at is observability metadata, not an auth gate. - } - } - - public function delete(string $id): void - { - try { - $this->db->execute("DELETE FROM {$this->table} WHERE id = :id", ['id' => $id]); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to revoke access token', layer: 'repository.auth', previous: $e); - } - } - - /** - * Delete every token whose absolute expiry has already passed. Returns the - * number of rows removed. Driver-portable — binds the cutoff rather than - * relying on a dialect-specific NOW(). - */ - public function deleteExpired(?\DateTimeImmutable $now = null): int - { - $cutoff = ($now ?? new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - return $this->db->execute( - "DELETE FROM {$this->table} WHERE expires_at IS NOT NULL AND expires_at <= :cutoff", - ['cutoff' => $cutoff] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to prune expired access tokens', layer: 'repository.auth', previous: $e); - } - } - - /** Count tokens whose expiry has passed (drives the prune command's --dry mode). */ - public function countExpired(?\DateTimeImmutable $now = null): int - { - $cutoff = ($now ?? new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - $row = $this->db->queryOne( - "SELECT COUNT(*) AS n FROM {$this->table} WHERE expires_at IS NOT NULL AND expires_at <= :cutoff", - ['cutoff' => $cutoff] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to count expired access tokens', layer: 'repository.auth', previous: $e); - } - - return (int) ($row['n'] ?? 0); - } -} diff --git a/plugins/Auth/Infrastructure/Persistence/RefreshTokenRepository.php b/plugins/Auth/Infrastructure/Persistence/RefreshTokenRepository.php deleted file mode 100644 index 6b07026..0000000 --- a/plugins/Auth/Infrastructure/Persistence/RefreshTokenRepository.php +++ /dev/null @@ -1,144 +0,0 @@ -db->execute( - 'INSERT INTO refresh_tokens - (token_id, family_id, user_id, token_hash, tenant_id, device, ip, expires_at, created_at) - VALUES (:tid, :fid, :uid, :hash, :tenant, :device, :ip, :exp, :now)', - [ - 'tid' => $tokenId, - 'fid' => $familyId, - 'uid' => $userId, - 'hash' => $tokenHash, - 'tenant' => ($tenantId === null || $tenantId === '') ? null : $tenantId, - 'device' => $device, - 'ip' => $ip, - 'exp' => $expiresAt->format('Y-m-d H:i:s'), - 'now' => self::now(), - ], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to store refresh token.', layer: 'repository.auth', previous: $e); - } - } - - public function findActiveByHash(string $tokenHash): ?RefreshTokenRecord - { - try { - $row = $this->db->queryOne( - 'SELECT token_id, family_id, user_id, tenant_id, revoked_at - FROM refresh_tokens - WHERE token_hash = :hash - AND revoked_at IS NULL - AND expires_at > :now - LIMIT 1', - ['hash' => $tokenHash, 'now' => self::now()], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to load refresh token.', layer: 'repository.auth', previous: $e); - } - - return $row === null ? null : RefreshTokenRecord::fromRow($row); - } - - public function findByHash(string $tokenHash): ?RefreshTokenRecord - { - try { - // Bounded by expiry — an expired token is just "unknown", not a reuse - // signal. Revoked-but-unexpired is the replay case. - $row = $this->db->queryOne( - 'SELECT token_id, family_id, user_id, tenant_id, revoked_at - FROM refresh_tokens - WHERE token_hash = :hash - AND expires_at > :now - LIMIT 1', - ['hash' => $tokenHash, 'now' => self::now()], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to load refresh token.', layer: 'repository.auth', previous: $e); - } - - return $row === null ? null : RefreshTokenRecord::fromRow($row); - } - - public function revoke(string $tokenId): void - { - $this->revokeIfActive($tokenId); - } - - public function revokeIfActive(string $tokenId): bool - { - try { - $affected = $this->db->execute( - 'UPDATE refresh_tokens SET revoked_at = :now WHERE token_id = :tid AND revoked_at IS NULL', - ['now' => self::now(), 'tid' => $tokenId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to revoke refresh token.', layer: 'repository.auth', previous: $e); - } - - return $affected > 0; - } - - public function revokeFamily(string $familyId): int - { - try { - return $this->db->execute( - 'UPDATE refresh_tokens SET revoked_at = :now WHERE family_id = :fid AND revoked_at IS NULL', - ['now' => self::now(), 'fid' => $familyId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to revoke token family.', layer: 'repository.auth', previous: $e); - } - } - - public function revokeAllForUser(string $userId): int - { - try { - return $this->db->execute( - 'UPDATE refresh_tokens SET revoked_at = :now WHERE user_id = :uid AND revoked_at IS NULL', - ['now' => self::now(), 'uid' => $userId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to revoke refresh tokens.', layer: 'repository.auth', previous: $e); - } - } - - private static function now(): string - { - return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - } -} diff --git a/plugins/Auth/Provider.php b/plugins/Auth/Provider.php deleted file mode 100644 index f793e9f..0000000 --- a/plugins/Auth/Provider.php +++ /dev/null @@ -1,366 +0,0 @@ - */ - public function requires(): array - { - // Mirrors module.json "requires". personal_access_tokens is a control-plane - // table, pinned to central. UserServiceContract verifies credentials for the - // session login flow; SessionPort (essential) carries the web/AJAX session. - return ['database.management', 'crypto.services', 'user.management', 'authorization.policy']; - } - - /** @return list */ - public function exposes(): array - { - return [ - AuthServiceContract::class, - \Plugins\Auth\API\Contracts\RefreshTokenServiceContract::class, - ]; - } - - public function register(ModuleContainer $container): void - { - // ONE nesting-aware transaction manager for ALL Auth writes. Every Auth - // repository resolves the per-request DatabasePort (the tenant connection - // TenantContextStage rebinds), so transactions MUST bracket that same - // connection — pinning this to the ConnectionManager default would open - // the transaction on central while the writes land in the tenant DB, - // leaving them effectively unbracketed. Shared (singleton) so composed - // flows (revokeOthers → establish) nest instead of double-beginning. - $container->singleton('auth.transaction', static fn(ModuleContainer $c) => - new \AlfacodeTeam\PhpServicePlatform\Kernel\Database\TransactionManager( - $c->make(DatabasePort::class), - ) - ); - - $container->bindInternal(PersonalAccessTokenRepository::class, static fn(ModuleContainer $c) => - new PersonalAccessTokenRepository( - // Tenant connection — auth credentials are tenant-scoped, so this - // is the per-request (tenant-rebound) DatabasePort, never the - // ConnectionManager default. - $c->make(DatabasePort::class), - env('AUTH_PAT_TABLE') ?: 'personal_access_tokens', - ) - ); - - // Device-session registry (central — auth_sessions is control-plane). - $container->bindInternal(\Plugins\Auth\Infrastructure\Persistence\DeviceSessionRepository::class, - static fn(ModuleContainer $c) => - new \Plugins\Auth\Infrastructure\Persistence\DeviceSessionRepository( - $c->make(DatabasePort::class), - ) - ); - - // Fingerprint + server-side session validation. Public bind (not exposed - // cross-module) so SessionAuthStage can resolve it from the request - // container on every stateful request. - $container->bind(\Plugins\Auth\Application\Services\DeviceSessionService::class, - static fn(ModuleContainer $c) => - new \Plugins\Auth\Application\Services\DeviceSessionService( - sessions: $c->make(\Plugins\Auth\Infrastructure\Persistence\DeviceSessionRepository::class), - ttlDays: (int) (\auth_config('session.ttl_days') ?? 30), - refreshDays: (int) (\auth_config('session.refresh_days') ?? 7), - fingerprintHeader: (string) (\auth_config('session.client_fingerprint_header') ?? 'X-Client-Fingerprint'), - transaction: $c->make('auth.transaction'), - ) - ); - - // RBAC bridge — resolves a user's roles/permissions from the - // Authorization plugin's policy store when it is loaded for the request; - // degrades to empty lists otherwise (optional dependency). - $container->bindInternal(\Plugins\Auth\Application\Auth\RoleResolver::class, static fn(ModuleContainer $c) => - new \Plugins\Auth\Application\Auth\RoleResolver( - $c->has(\Plugins\Authorization\API\Contracts\AuthorizationServiceContract::class) - ? $c->make(\Plugins\Authorization\API\Contracts\AuthorizationServiceContract::class) - : null, - ) - ); - - $container->bind(AuthServiceContract::class, static fn(ModuleContainer $c) => - new AuthService( - tokens: $c->make(PersonalAccessTokenRepository::class), - hasher: $c->make(HashingPort::class), - jwtSecret: env('JWT_SECRET') ?: '', - jwtAlgo: env('JWT_ALGO') ?: 'HS256', - jwtIssuer: env('JWT_ISSUER') ?: null, - jwtAudience: env('JWT_AUDIENCE') ?: null, - cache: $c->has(CachePort::class) ? $c->make(CachePort::class) : null, - jwtPrivateKey: self::readKey(env('JWT_PRIVATE_KEY'), env('JWT_PRIVATE_KEY_FILE')), - jwtKid: env('JWT_KID') ?: null, - roles: $c->make(\Plugins\Auth\Application\Auth\RoleResolver::class), - transaction: $c->make('auth.transaction'), - // Fills the display-identity claims (preferred_username/email) - // on issued credentials when the caller doesn't supply them. - // LAZY closure — an eager make() recurses: AuthService → - // UserService → MembershipService → AuthService (bind() has no - // cycle guard, so it loops until max_execution_time). - users: $c->has(\Plugins\User\API\Contracts\UserServiceContract::class) - ? static fn() => $c->make(\Plugins\User\API\Contracts\UserServiceContract::class) - : null, - ) - ); - - // Session login/logout controller for web + AJAX. Credentials verified by - // the User module; SessionPort (essential) carries the stateful session. - // Session login/logout drives the AuthManager 'web' guard; the - // controller only needs the device registry for the list/revoke-by-id - // endpoints (the login flow itself goes through the guard). - $container->bindInternal(SessionAuthController::class, static fn(ModuleContainer $c) => - new SessionAuthController( - $c->make(\Plugins\Auth\Application\Services\DeviceSessionService::class), - ) - ); - - // Self-service PAT management controller (GET/POST/DELETE /auth/tokens). - $container->bindInternal(\Plugins\Auth\Infrastructure\Http\Controllers\PersonalAccessTokenController::class, - static fn(ModuleContainer $c) => - new \Plugins\Auth\Infrastructure\Http\Controllers\PersonalAccessTokenController( - $c->make(AuthServiceContract::class), - ) - ); - - // Refresh-token session store — TENANT-scoped, like every other auth - // credential store (auth_sessions, personal_access_tokens). Central holds - // no sessions and no tokens, so this resolves the per-request DatabasePort - // (rebound to the tenant by TenantContextStage) and NOT the - // ConnectionManager default. `refresh_tokens` is created by this plugin's - // database/tenant-template/ migration; central never has that table. - $container->bindInternal(\Plugins\Auth\Application\Ports\RefreshTokenStore::class, static fn(ModuleContainer $c) => - new \Plugins\Auth\Infrastructure\Persistence\RefreshTokenRepository( - $c->make(DatabasePort::class), - ) - ); - - // Refresh-token service (revocable long-lived first-party sessions). - $container->bind(\Plugins\Auth\API\Contracts\RefreshTokenServiceContract::class, static fn(ModuleContainer $c) => - new \Plugins\Auth\Application\Services\RefreshTokenService( - tokens: $c->make(\Plugins\Auth\Application\Ports\RefreshTokenStore::class), - auth: $c->make(AuthServiceContract::class), - refreshTtl: (int) (env('AUTH_REFRESH_TTL') ?: 2592000), - accessTtl: (int) (env('AUTH_REFRESH_ACCESS_TTL') ?: 900), - transaction: $c->make('auth.transaction'), - ) - ); - - $container->bindInternal(\Plugins\Auth\Infrastructure\Http\Controllers\AuthTokenController::class, - static fn(ModuleContainer $c) => - new \Plugins\Auth\Infrastructure\Http\Controllers\AuthTokenController( - $c->make(\Plugins\Auth\API\Contracts\RefreshTokenServiceContract::class), - ) - ); - - // Transient-token controller (POST /auth/token/refresh) for first-party SPAs. - $container->bindInternal(\Plugins\Auth\Infrastructure\Http\Controllers\TransientTokenController::class, - static fn(ModuleContainer $c) => - new \Plugins\Auth\Infrastructure\Http\Controllers\TransientTokenController( - $c->make(AuthServiceContract::class), - ) - ); - - // Mobile auth flow (old __DEV__ /v1/auth/*). PUBLIC binds so a project - // route override (adding "requires": ["auth.identity","oauth.server"]) - // can resolve them — that override is how PKCE mode is enabled; without - // it the OAuth2 module isn't in the graph and PKCE returns a clear 4xx. - $container->bind(\Plugins\Auth\Application\Services\MobileAuthService::class, static fn(ModuleContainer $c) => - new \Plugins\Auth\Application\Services\MobileAuthService( - users: $c->make(UserServiceContract::class), - auth: $c->make(AuthServiceContract::class), - oauthFlow: $c->has(\Plugins\OAuth2\Application\Ports\AuthorizationFlow::class) - ? $c->make(\Plugins\OAuth2\Application\Ports\AuthorizationFlow::class) - : null, - autoVerify: !\in_array(strtolower((string) (env('AUTH_MOBILE_AUTOVERIFY') ?? '1')), ['0', 'false', 'off', 'no'], true), - ) - ); - - $container->bind(\Plugins\Auth\Infrastructure\Http\Controllers\MobileAuthController::class, - static fn(ModuleContainer $c) => - new \Plugins\Auth\Infrastructure\Http\Controllers\MobileAuthController( - $c->make(UserServiceContract::class), - $c->make(\Plugins\Auth\Application\Services\MobileAuthService::class), - ) - ); - - // Default user provider (ModelUserProvider over the central identity store). - // Passing AuthServiceContract lights up the HasApiTokens surface on proxies. - // The tenant gate makes membership part of the fetch: on a tenant-scoped - // request a user with no active seat in that tenant simply does not exist. - $container->bind(\Plugins\Auth\Application\Ports\UserProvider::class, static fn(ModuleContainer $c) => - new \Plugins\Auth\Application\Auth\ModelUserProvider( - $c->make(UserServiceContract::class), - 'users', - ['identifier', 'email', 'username'], - $c->make(AuthServiceContract::class) - ) - ); - - // Password reset broker (CachePort-backed). Bound only when a cache is - // available — the reset flow needs a token store. - if ($container->has(CachePort::class)) { - $container->bind(\Plugins\Auth\Application\Ports\PasswordBroker::class, static fn(ModuleContainer $c) => - new \Plugins\Auth\Application\Auth\PasswordResetBroker( - $c->make(UserServiceContract::class), - $c->make(CachePort::class), - otpTtlSeconds: (int) (env('AUTH_OTP_TTL') ?: 600), - // A completed reset must revoke every credential issued under - // the old password. Both are Auth's own services; passed - // optionally so the broker still works in unit tests and in - // deployments without a database-backed session store. - refreshTokens: $c->has(\Plugins\Auth\API\Contracts\RefreshTokenServiceContract::class) - ? $c->make(\Plugins\Auth\API\Contracts\RefreshTokenServiceContract::class) - : null, - devices: $c->has(\Plugins\Auth\Application\Services\DeviceSessionService::class) - ? $c->make(\Plugins\Auth\Application\Services\DeviceSessionService::class) - : null, - ) - ); - - // OTP forgot-password endpoints (old __DEV__ mobile flow). MailPort - // is OPTIONAL — without a mailer the OTP is only visible in dev - // transports; the flow itself keeps working. - $container->bindInternal(\Plugins\Auth\Infrastructure\Http\Controllers\PasswordResetController::class, - static fn(ModuleContainer $c) => - new \Plugins\Auth\Infrastructure\Http\Controllers\PasswordResetController( - $c->make(\Plugins\Auth\Application\Ports\PasswordBroker::class), - $c->has(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort::class) - ? $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort::class) - : null, - ) - ); - } - - // AuthManager — manages named guards + providers. Request is injected per - // use via the InteractsWithAuthManager controller concern (setRequest). - $container->bind(\Plugins\Auth\Application\Auth\AuthManager::class, static fn(ModuleContainer $c) => - new \Plugins\Auth\Application\Auth\AuthManager( - config: \auth_config(), - providerFactory: static function (string $name) use ($c): ?\Plugins\Auth\Application\Ports\UserProvider { - // Resolve the named provider from config; only 'model' is - // built-in — extend this switch to back other stores. - $driver = \auth_config('providers.' . $name . '.driver'); - return $driver === 'model' - ? new \Plugins\Auth\Application\Auth\ModelUserProvider( - $c->make(UserServiceContract::class), - $name, - ['identifier', 'email', 'username'], - $c->make(AuthServiceContract::class) - ) - : null; - }, - session: $c->has(SessionPort::class) ? $c->make(SessionPort::class) : null, - auth: $c->make(AuthServiceContract::class), - statefulFactory: static function (string $name, \Plugins\Auth\Application\Ports\UserProvider $provider, \AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request $request) use ($c): ?\Plugins\Auth\Application\Ports\StatefulGuard { - if (!$c->has(SessionPort::class)) { - return null; - } - - // WRITE-side guard for the old flow: - // auth()->guard('web')->attempt($credentials, remember: true) - $guard = new \Plugins\Auth\Application\Auth\StatefulSessionGuard( - name: $name, - provider: $provider, - session: $c->make(SessionPort::class), - users: $c->make(UserServiceContract::class), - cookies: $c->has(\Plugins\Cookie\Infrastructure\CookieJar::class) - ? $c->make(\Plugins\Cookie\Infrastructure\CookieJar::class) - : null, - devices: $c->make(\Plugins\Auth\Application\Services\DeviceSessionService::class), - ); - - return $guard->setRequest($request); - }, - refreshTokens: $c->make(\Plugins\Auth\API\Contracts\RefreshTokenServiceContract::class), - accessTtl: (int) (env('AUTH_MOBILE_ACCESS_TTL') ?: 3600), - ) - ); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Token verification (JWT / PAT) runs in the SecurityGateway via project - // ->withSecurity([...]). SESSION auth (web + AJAX) cannot run there — the - // session is only opened at after.load — so attach a session Identity just - // after StartSessionStage and before the route `auth` filter. - $http->hook('after.load', \Plugins\Auth\Infrastructure\Http\Stages\SessionAuthStage::class, priority: \Plugins\Auth\Infrastructure\Http\Stages\SessionAuthStage::PRIORITY); - - // Maintenance command (auth:tokens:prune). Deferred so only CLI processes - // pay for it. - // - // KNOWN LIMITATION: `personal_access_tokens` is TENANT-scoped (central - // holds no tokens), but this still resolves the ConnectionManager default - // because the CLI has no per-request tenant to route to — so the command - // currently targets a table central does not have. Pruning must iterate - // the tenant registry and run once per tenant connection; until it does, - // treat this command as inoperative rather than as a central pin. - $cli->defer(static function (CliPipeline $cli): void { - $c = new ModuleContainer($cli->container()); - $c->setScope('database.management'); - (new \Plugins\Database\Provider())->register($c); - - $repository = new PersonalAccessTokenRepository( - $c->make(DatabaseConnectionManagerContract::class)->default(), - env('AUTH_PAT_TABLE') ?: 'personal_access_tokens', - ); - - $cli->command(new \Plugins\Auth\Infrastructure\Cli\PruneAccessTokensCommand($repository)); - }); - } - - - /** - * Resolve a PEM signing key from either an inline env value or a file path - * (the file form is preferred in production — keys stay off the process - * environment). Returns null when neither is configured (symmetric mode). - */ - private static function readKey(mixed $inline, mixed $file): ?string - { - $file = is_string($file) ? trim($file) : ''; - if ($file !== '' && is_readable($file)) { - $contents = file_get_contents($file); - if ($contents !== false && trim($contents) !== '') { - return $contents; - } - } - - $inline = is_string($inline) ? trim($inline) : ''; - - // Allow literal "\n" escapes from single-line .env values. - return $inline !== '' ? str_replace('\n', "\n", $inline) : null; - } -} diff --git a/plugins/Auth/README.md b/plugins/Auth/README.md deleted file mode 100644 index b28dfed..0000000 --- a/plugins/Auth/README.md +++ /dev/null @@ -1,611 +0,0 @@ -# Auth — Authentication (`solves: auth.identity`) - -The **single home for authentication** on the AlfacodeTeam PhpServicePlatform. It -decides *who* a caller is and gives you ergonomic ways to work with that identity. -It does **not** do authorization policy (that's your service layer, or -`Plugins\Authorization`) and it is **not** the multi-tenant control plane (that's -`Plugins\Tenancy`). - -> 📄 A full typeset walkthrough ships alongside this file: [`AUTH_GUIDE.pdf`](AUTH_GUIDE.pdf). -> Architecture notes: `docs/ai-context/25_AUTH.md`. - ---- - -## Part I — Requirements - -### Module manifest - -| Field | Value | -|---|---| -| `solves` | `auth.identity` | -| `requires` | `database.management`, `crypto.services`, `user.management`, `authorization.policy` | -| `exposes` | `AuthServiceContract`, `RefreshTokenServiceContract` | -| `views` | `resources/views` → namespace `auth`, `global: false` | -| Activation | **on-demand** (SecurityLayers are wired separately, in the bootstrap) | - -Everything else in this plugin (`AuthManager`, `AuthService`, `DeviceSessionService`, -`RefreshTokenService`, repositories) is **internal**. Other plugins reach Auth only -through the two exposed contracts. - -### Kernel ports it needs - -| Port | Used for | Required? | -|---|---|---| -| `DatabasePort` | PAT / refresh-token / device-session tables | **yes** | -| `HashingPort` | password hashing + verification (`crypto.services`) | **yes** | -| `CachePort` | JWT `jti` deny-list, password-reset tokens + OTP | for revocation and the whole `/auth/password/*` flow | -| `SessionPort` | stateful web login (`Plugins\Session`, essential) | for session auth | -| `MailPort` | OTP email + password-changed notification | optional — flow degrades without it | - -`PasswordBroker` is only bound **when a `CachePort` is available** — no cache, no -password-reset endpoints. - -### Companion plugins - -| Plugin | Why | -|---|---| -| `Plugins\User` (`user.management`) | the central identity store behind every provider | -| `Plugins\Session` + `Plugins\Cookie` | **essential** — required for session login and remember-me | -| `Plugins\Authorization` (`authorization.policy`) | roles/permissions stamped into sessions and JWT claims | -| `Plugins\Mail` + `Plugins\View` | rendering and sending the two auth emails | -| `Plugins\OAuth2` | OAuth 2.1 / OIDC **authorization server** — a different concern, see below | - -### Database — every auth table is TENANT-scoped - -**Central holds no sessions and no tokens.** All three tables are created by this -plugin's `database/tenant-template/` migrations and live in each **tenant** -database; the repositories resolve the per-request `DatabasePort` that -`TenantContextStage` rebinds. - -| Table | Holds | Migration | -|---|---|---| -| `personal_access_tokens` | first-party user API keys (hash only) | `2026_06_05_000001_*`, `2026_06_27_000002_*` | -| `refresh_tokens` | revocable long-lived sessions, `family_id` lineage | `2026_07_04_000002_*` | -| `auth_sessions` | device-session registry + fingerprints | `2026_07_12_000001_*` | - -```bash -hkm tenants:migrate # applies tenant-template to every tenant DB -``` - -> Do **not** move these to the central connection. A repository pinned to -> `ConnectionManager->default()` will query a table central does not have. - -### Configuration - -#### Environment (all optional — declared in `module.json` `config[]`) - -| Key | Default | Meaning | -|---|---|---| -| `JWT_SECRET` | — | HMAC signing secret (HS*) | -| `JWT_ALGO` | `HS256` | signing algorithm — pin ONE | -| `JWT_ISSUER` / `JWT_AUDIENCE` | — | `iss` / `aud`, verified on the way in | -| `JWT_PRIVATE_KEY` / `JWT_PRIVATE_KEY_FILE` | — | PEM for RS/ES/PS asymmetric signing | -| `JWT_KID` | — | key id in the JWT header (rotation) | -| `AUTH_PAT_TABLE` | `personal_access_tokens` | PAT table override | -| `AUTH_REFRESH_TTL` | `2592000` (30d) | refresh-token lifetime | -| `AUTH_REFRESH_ACCESS_TTL` | `900` | access JWT minted by a rotation | -| `AUTH_SESSION_TTL` | `30` (days) | absolute device-session lifetime | -| `AUTH_SESSION_REFRESH` | `7` (days) | rolling window — expiry slides forward on activity | -| `AUTH_FINGERPRINT_HEADER` | `X-Client-Fingerprint` | client-supplied fingerprint header | -| `AUTH_MOBILE_ACCESS_TTL` | — | mobile access-token lifetime | -| `AUTH_MOBILE_AUTOVERIFY` | on | auto-verify email on mobile register (`0` disables) | -| `AUTH_OTP_TTL` | `600` | password-reset OTP lifetime, seconds | -| `AUTH_GUARD` / `AUTH_PROVIDER` | `web` / `users` | defaults read by `config/auth.php` | - -Read them with `env()` — **never `getenv()`** (`.env` values are injected into -`$_ENV`/`$_SERVER` only). - -#### `config/auth.php` - -Guard/provider maps. Resolution order: `projects//config/auth.php` (copy it -there to override) → `plugins/Auth/config/auth.php`. Read via `auth_config()`. - -```php -return [ - 'defaults' => ['guard' => env('AUTH_GUARD', 'web'), 'provider' => env('AUTH_PROVIDER', 'users')], - 'guards' => [ - 'web' => ['driver' => 'session', 'provider' => 'users'], - 'api' => ['driver' => 'token', 'provider' => 'users'], - 'jwt' => ['driver' => 'jwt', 'provider' => 'users'], - 'request' => ['driver' => 'request', 'provider' => 'users'], - ], - 'providers' => ['users' => ['driver' => 'model']], - 'session' => [ - 'ttl_days' => (int) (env('AUTH_SESSION_TTL') ?: 30), - 'refresh_days' => (int) (env('AUTH_SESSION_REFRESH') ?: 7), - 'client_fingerprint_header' => env('AUTH_FINGERPRINT_HEADER') ?: 'X-Client-Fingerprint', - ], -]; -``` - -### Wiring checklist - -1. Register the provider: `->withModules([..., \Plugins\Auth\Provider::class])`. -2. Wire the verification layers in `->withSecurity([...])` — they take **port - instances**, so they cannot self-register (step 3 below). -3. Make `Plugins\Session` + `Plugins\Cookie` essential (session login), e.g. via - `proj.json` `"essentials"`. -4. Run the tenant migrations: `hkm tenants:migrate`. -5. Any route using the AuthManager/PAT surface declares `"requires": - ["auth.identity"]`; protect it with the `auth` route filter. -6. Routes that **send mail** (`/auth/password/forgot`, `/auth/password/reset`) - declare `"requires": ["mail.delivery", "view.rendering"]` — do not rely on - another module pulling them in transitively. - ---- - -## Part II — Concepts - -### The one split to remember - -| | Where it lives | Classes | -|---|---|---| -| **Issuance** — mint credentials | service layer | `AuthServiceContract`, `RefreshTokenServiceContract` | -| **Verification** — check a credential | SecurityGateway (before any module loads) | `JwtAuthLayer`, `PersonalAccessTokenLayer`, `SessionAuthStage` | - -The principal produced by verification is the kernel's immutable `Identity`, -carried on the request. Everything else here (Guard, AuthManager, AuthUserProxy) -is a **projection** over that `Identity` — none of them replace it. - -### The five authentication methods - -| Method | Credential | Verified / issued by | -|---|---|---| -| JWT (Bearer) | `Authorization: Bearer ` | `JwtAuthLayer` / `AuthService::issueJwt` | -| Personal access token | `Authorization: Bearer .` | `PersonalAccessTokenLayer` / `AuthService::createPersonalAccessToken` | -| Session (web/AJAX) | session cookie + `remember_web` cookie | `SessionAuthStage` / `AuthService::startSession` | -| Refresh token | opaque token in POST body | `RefreshTokenService::rotate` / `::issue` | -| Transient token | session → short JWT | `TransientTokenController` | - -### 1. The principal: `Identity` - -```php -final readonly class Identity { - public string $userId; // '' for a guest - public string $tenantId; // '' = central / unscoped - public array $roles; // list - public array $permissions; // list (PAT abilities / OAuth scopes) - public string $tokenType; // 'jwt' | 'api_key' | 'session' | 'none' - public string $username; // display identity — best-effort, '' when unknown - public string $email; - public string $fullName; // tenant user_profiles; tenant-scoped credentials only - public ?string $avatarUrl; - public function hasRole(string $r): bool; - public function hasPermission(string $p): bool; // honours '*' - public function isGuest(): bool; -} -``` - -Read it with `$request->identity()`. Do **not** invent another principal type. - -### 2. Verification — SecurityGateway layers - -Wire the layers in the kernel builder; they run before any module loads and -**never throw** (they return a `SecurityVerdict`). - -```php -->withSecurity([ - new CsrfTokenLayer(...), // the only layer the kernel ships - new JwtAuthLayer( - secret: env('JWT_SECRET'), - algo: env('JWT_ALGO', 'HS256'), - issuer: env('JWT_ISSUER'), - audience: env('JWT_AUDIENCE'), - leeway: 0, - revocations: $cachePort, // jti deny-list - ), - new PersonalAccessTokenLayer($databasePort), // Bearer . -]); -``` - -- **`JwtAuthLayer`** — signature, `iss`/`aud`, expiry (+leeway), `jti` deny-list - via `CachePort`. Pin a *single* algorithm; never let the token's `alg` choose. -- **`PersonalAccessTokenLayer`** — hashes `.`, enforces `expires_at`, - loads abilities into `Identity.permissions`. -- No header → guest. Bad credential → `deny(401)`. - -Session auth **can't** be a SecurityLayer (the session opens at `after.load`), so -`SessionAuthStage` runs at `after.load` **priority 22** and attaches a -`tokenType: 'session'` Identity — the same `auth` route filter then covers token -*and* session callers. A token Identity already present is left untouched. - -### 3. Issuance — `AuthServiceContract` - -| Method | Notes | -|---|---| -| `issueJwt(userId, claims, ttl): string` | adds `iat/nbf/exp/jti` + `iss/aud`; asymmetric signs with the private key | -| `revokeJwt(jti, ttl)` | deny-lists a `jti` (key `auth:jwt:revoked:`) | -| `createPersonalAccessToken(userId, name, abilities, ttl)` | `{id, token}`; plaintext once, hash stored | -| `revokePersonalAccessToken(id)` | | -| `tokensFor(userId): list` | a user's PATs, no secrets | -| `guard(Request): Guard` | read-only projection | -| `startSession(session, userId, roles, perms, tenantId, username, email, fullName, avatarUrl)` | rotates the session id, stores identity | -| `endSession(session)` | invalidate + rotate | -| `hashPassword` / `verifyPassword` | bcrypt/argon2, timing-safe | - -```php -$jwt = $auth->issueJwt('user-123', [ - 'roles' => ['admin'], 'permissions' => ['invoice:create'], 'tnt' => 'tenant-9', -], ttlSeconds: 3600); -$auth->revokeJwt($jti, 3600); // kill it before exp -``` - -### 4. Guard + hierarchical scopes - -```php -$g = Guard::fromRequest($request); // or $auth->guard($request) -$g->check(); $g->guest(); $g->id(); $g->tenantId(); -$g->via(); // 'jwt' | 'api_key' | 'session' | 'none' -$g->viaToken(); $g->viaSession(); -$g->hasRole('admin'); $g->hasPermission('invoice:create'); -$g->hasScope('reports:export'); // hierarchical -$t = Guard::actingAs('u1', ['reports'], roles: ['analyst']); // test helper -``` - -Scopes/abilities are **colon-hierarchical** — a held scope satisfies every -descendant (`ScopeInheritance::satisfies`): - -```php -ScopeInheritance::satisfies(['admin'], 'admin:users:write'); // true -ScopeInheritance::satisfies(['admin:users'], 'admin:posts'); // false -ScopeInheritance::satisfies(['adm'], 'admin'); // false (boundary) -// '*' grants all; bare (PAT) and 'scope:'-namespaced (OAuth2) both match. -``` - -### 5. AuthManager — named guards + providers - -Config-driven (`config/auth.php`, read via `auth_config()`), no globals; guards -resolve an `AuthUserProxy` that **emits** an `Identity`. - -```php -// READ -$manager->guard('api')->user(); // ?Authenticatable (AuthUserProxy) -$manager->guard('jwt')->identity(); // kernel Identity -$manager->user('api'); $manager->check(); $manager->id(); -$manager->provider('users'); -$manager->extend('sso', fn($req,$name,$cfg) => new GuardAccessor(...)); -$manager->extendProvider('ldap', fn($name) => new LdapUserProvider(...)); -$manager->resolveUsersUsing($closure); -$manager->forgetGuards(); // Swoole: clear per-request cache -$manager->setRequest($request); // done for you by the controller concern - -// WRITE — the front-door ergonomic (session guard): -$manager->guard('web')->attempt(['email' => …, 'password' => …], remember: true); -$manager->guard('web')->logout(); -$manager->guard('web')->logoutOtherDevices($password); -// (a stateless guard throws on a write — attempt/logout need a session driver) - -// ISSUE — stateless credentials, one call, no reaching into AuthService: -$manager->issueToken('u1', ['roles' => ['user']], 3600); // access JWT -$manager->issueTokenPair('u1', device: $ua, ip: $ip); // { accessToken, refreshToken, … } -``` - -**AuthManager is the single front door.** The Auth plugin's own controllers route -through it — `SessionAuthController` drives `$this->auth('web')->attempt()` / -`->logout()` / `->logoutOtherDevices()`; `MobileAuthController` issues via -`$this->authManager()->issueTokenPair()` / `->issueToken()`. Controllers never -touch `AuthService`/`RefreshTokenService` directly. The one thing AuthManager does -NOT own is verifying an INCOMING token on a protected request — that runs in the -kernel SecurityGateway *before* any module loads, which is a GDA requirement, not -a choice. Other PLUGINS cross the boundary through the published -`AuthServiceContract` (AuthManager is Auth-internal, deliberately not exposed). - -- **`ModelUserProvider`** — resolves users from `UserServiceContract` (no ORM); - `retrieveByCredentials` does the full timing-safe verify. -- **`AuthUserProxy`** — lightweight current user; `identity()` + HasApiTokens - (`tokens()/token()/tokenCan()/createToken()`). Not the principal. -- **Drivers** (`Infrastructure/Auth/Drivers`) — `session`, `jwt`/`token` - (rehydrate the gateway verdict), `request` (any). **Filesystem-scanned once per - process** (a documented boot-time exception to the no-runtime-discovery rule). - -**`StatefulSessionGuard`** (interactive login): - -```php -$guard->attempt(['email' => $e, 'password' => $p], remember: true); -$guard->validate($creds); $guard->once($creds); -$guard->loginUsingId('u1', remember: true); $guard->login($user, remember: true); -$guard->logout(); $guard->logoutOtherDevices($password); $guard->viaRemember(); -$guard->basic('email'); // HTTP Basic → null on success, 401 Response on fail -``` - ---- - -## Part III — The flows - -### 6. Session login + remember-me - -``` -POST /auth/login { identifier|email, password, remember?, redirectTo? } → 200 {user, redirectTo} | 401 -POST /auth/logout → 204 -GET /auth/me → identity | 401 -``` - -`remember=true` issues an encrypted `remember_web` cookie holding a -`userId|token` **recaller** (`Recaller` — a flat pipe string, never unserialized). -With no live session, `SessionAuthStage` validates it by the token's SHA-256 hash -(`UserServiceContract::findByRememberToken`), re-opens the session, and **rotates** -the token + cookie (single-use window). Logout clears both. - -**Post-login redirect.** The Session plugin's `StartSessionStage` records the last -eligible page view (GET + 2xx, HTML or Pageflow page object; auth/OAuth/API/asset -paths exempt — extend with `SESSION_PREVIOUS_EXEMPT`) under -`StartSessionStage::PREVIOUS_URL`. On successful login the target is: explicit -`redirectTo` on the request (query/body) → the recorded previous page (pulled -one-time) → `/`. Browser POSTs get a 302; AJAX callers get `redirectTo` in the -JSON payload. Every candidate passes an open-redirect guard (relative `/…` paths -only). SocialAuth's web callback honours the same recorded page. - -**Display identity.** `AuthService` fills `username`/`email` from the central user -store at issuance when the caller didn't supply them; they ride as OIDC claims -(`preferred_username`, `email`, `name`) on JWTs and as session keys, so -verification layers rebuild a full `Identity` without a DB read. The user-store -dependency is a lazy closure — never resolve `UserServiceContract` eagerly in the -AuthService factory (container cycle). - -### 7. Device sessions — fingerprint + registry - -Every stateful login is bound to a device **fingerprint** -(`X-Client-Fingerprint` header, else `sha256(ip|user-agent)`) and registered in -the tenant `auth_sessions` table. A request that can't reproduce the fingerprint, -or whose server-side row was revoked/expired, loses the session immediately — even -if the cookie is still live. Rolling refresh slides the expiry forward on -activity. `DeviceSessionService` orchestrates it; the `config/auth.php` `session` -block tunes it. - -``` -GET /auth/sessions → list this user's devices (current flagged) -DELETE /auth/sessions/{id} → sign out one device -POST /auth/logout-other-devices { password } → revoke every OTHER device -``` - -```php -$devices->establish($session, $request, $userId); // at login -$devices->verify($session, $request); // per request (SessionAuthStage) -$devices->revokeOthers($session, $request, $userId);// keep this one -$devices->revokeById($userId, $sessionId); -$devices->revokeAll($userId); // keep NONE — used by password reset -$devices->listDevices($userId, $session); -$devices->teardown($session); // at logout -``` - -### 8. Personal access tokens (self-service) - -First-party user API keys — **not** OAuth clients, **not** used by session login. - -``` -GET /auth/tokens → list mine (no secrets) -POST /auth/tokens { name, abilities[], ttl? } → 201 { id, token } (once) -DELETE /auth/tokens/{id} → 204 (mine only, else 404) -``` - -```php -$r = $auth->createPersonalAccessToken('u1', 'ci', ['deploy:run'], 86400); -$user = $manager->user(); -$user->tokens(); $user->tokenCan('deploy:run'); $user->createToken('backup', ['storage:read']); -``` - -Only the SHA-256 is stored. - -### 9. Refresh tokens (revocable sessions) - -`RefreshTokenServiceContract` — lives here, not in Tenancy (auth ≠ tenancy). - -```php -$issued = $refresh->issue('u1', tenantId: null, device: $ua, ip: $ip); // raw token shown ONCE -$rot = $refresh->rotate($rawToken, $ip); // → RefreshRotation -$refresh->revoke($rawToken); -$refresh->revokeAllForUser('u1'); // logout everywhere -``` - -``` -POST /auth/refresh { token } → new access JWT + rotated refresh | 401 -POST /auth/refresh/logout { token } → 204 -``` - -**One-time-use rotation with family reuse detection**: replaying a revoked token -(or losing a rotation race) burns the whole `family_id` and 401s. Only hashes are -stored, in the **tenant** `refresh_tokens` table. The `tenantId` argument is a -passthrough hint for the `tnt` claim, never re-verified on refresh (the -tenant-seat check happens at tenant-select). - -### 10. Transient token (first-party SPA) - -`POST /auth/token/refresh` (auth-filtered) — a session-authenticated SPA mints a -short-lived (900s) JWT carrying the session identity's real permissions. A -Bearer/PAT caller is refused (session only). - -### 11. Mobile JWT flow (`/auth/mobile/*`) - -- `POST /auth/mobile/login` `{ email|identifier, password }` → `{ user, tokens }` - (access JWT + refresh). Add `client_id` + PKCE params (`redirect_uri`, `scope`, - `state`, `code_challenge`, `code_challenge_method`) to switch to the **PKCE** - shape → `{ code, state }`, exchanged at `POST /oauth/token` with the - `code_verifier`. PKCE needs the route to also require `oauth.server`. -- `POST /auth/mobile/register` → same two shapes; auto-verifies the email - (`AUTH_MOBILE_AUTOVERIFY=0` to disable). -- `POST /auth/mobile/logout` (Bearer) → blocklists the access token's `jti`. -- Refresh stays at `POST /auth/refresh`. - -### 12. Password reset (OTP) - -Three steps, `CachePort`-backed — **no token table**. The cache must be -**cross-process** (Redis, or a file-backed adapter); a per-request in-memory cache -loses the OTP between the two requests and every code looks instantly expired. - -``` -POST /auth/password/forgot { email } → 200 ALWAYS (enumeration-safe) -POST /auth/password/verify-otp { email, otp } → { resetToken } | 400 -POST /auth/password/reset { email, token, password, - password_confirmation? } → 200 | 400 | 422 -``` - -```php -// Or drive it directly through the port: -$res = $broker->sendResetLink('alice@example.com'); // → token | INVALID_USER | THROTTLED -$sent = $broker->sendOtp('alice@example.com'); // → ['otp' =>…, 'email' =>…] | null -$token = $broker->verifyOtp('alice@example.com', '123456'); // → reset token | null -$status = $broker->reset('alice@example.com', $token, 'N3wPassw0rd!'); // PASSWORD_RESET -$ok = $broker->validateToken('alice@example.com', $token); -``` - -Statuses: `PASSWORD_RESET`, `RESET_LINK_SENT`, `INVALID_USER`, `INVALID_TOKEN`, -`THROTTLED`. - -**Lifetimes & limits** - -| Thing | Value | Key | -|---|---|---| -| Reset token | 3600s, one-time | `auth:pwreset:tok:sha256(email)` | -| OTP | `AUTH_OTP_TTL` (600s), one-time, 6 digits | `auth:pwreset:otp:…` | -| Re-issue throttle | 60s per account | `auth:pwreset:thr:…` | -| Wrong-OTP budget | **5 per account**, then the code is burned | `auth:pwreset:try:…` | - -The account is addressed by the cache key `sha256(email)`, so a token minted for -one address can never reset another — swapping the email swaps the slot it is -compared against, and `hash_equals` fails. - -**What a completed reset does** - -1. Sets the password (`UserServiceContract::resetPassword` — transactional, - audited, optional HIBP breach screening via `USER_BREACH_CHECK`). -2. Clears the remember-me token. -3. **Revokes every refresh token** for the user. -4. **Revokes every device session** for the user. -5. Burns the reset token, the throttle, and any outstanding OTP. -6. Emails a **password-changed notification** (best-effort). - -Revocation happens *before* the token burn, deliberately: if a sweep fails the -reset is not reported as successful and the token stays valid for a retry (the -password write is idempotent). - -**Server-side validation on `reset`** — min 8 bytes, max 4096 (hashing-DoS -guard), no NUL byte, password ≠ email, and `password_confirmation` enforced -whenever the client sends it. - -### 13. Emails - -Two templates in `resources/views/`, namespace `auth`, `global: false`: - -| View | Sent when | -|---|---| -| `auth::password-otp` | `/auth/password/forgot` — the 6-digit code | -| `auth::password-changed` | after a successful reset — the takeover tripwire | - -Neither contains a login link or a token beyond the OTP itself. Both are -brand-neutral inline-CSS HTML. **Override** either from a project by placing your -own `resources/views/auth/password-otp.php` in the project view path — the -project-first cascade wins (see RESOURCE RESOLUTION in `CLAUDE.md`). - -Both routes must declare `"requires": ["mail.delivery", "view.rendering"]`. -Without a bound `MailPort` the flow still works — it just sends nothing. - -### 14. Social sign-in (`Plugins\SocialAuth`, solves `auth.social`) - -- `GET /auth/social/{driver}` → provider redirect · `GET /auth/social/{driver}/callback` - → session login + redirect (web), or `?mode=token` → `{ user, tokens }`. -- `POST /auth/social/{driver}/token` — native-SDK sign-in: verifies a Google - `access_token`/`id_token` or an Apple `identity_token` (against Apple's JWKS) - before find-or-create. Links live in `social_identities`. - -### 15. RBAC via Casbin (`Plugins\Authorization`, solves `authorization.policy`) - -When loaded, a user's roles + effective permissions are read from the policy store -and stamped into the session and JWT claims at login/issuance (`RoleResolver`). -Protect a route declaratively: - -```jsonc -{ "method": "PUT", "path": "/api/users/{id}", "handler": "…", - "filters": ["auth", "can:users,edit"], "requires": ["authorization.policy"] } -``` - -Seed the shipped role hierarchy: `hkm authz:seed`. - ---- - -## Part IV — Reference - -### Route reference - -| Method | Path | Filters | Extra `requires` | -|---|---|---|---| -| POST | `/auth/login` | `throttle:10,1` | | -| POST | `/auth/logout` | | | -| GET | `/auth/me` | | | -| GET | `/auth/sessions` | `auth` | | -| DELETE | `/auth/sessions/{id}` | `auth` | | -| POST | `/auth/logout-other-devices` | `auth`, `throttle:5,1` | | -| GET | `/auth/tokens` | `auth` | | -| POST | `/auth/tokens` | `auth`, `throttle:20,1` | | -| DELETE | `/auth/tokens/{id}` | `auth` | | -| POST | `/auth/token/refresh` | `auth` | | -| POST | `/auth/refresh` | `throttle:30,1` | | -| POST | `/auth/refresh/logout` | | | -| POST | `/auth/mobile/login` | `throttle:10,1` | | -| POST | `/auth/mobile/register` | `throttle:6,1` | | -| POST | `/auth/mobile/logout` | `auth` | | -| POST | `/auth/password/forgot` | `throttle:5,1` | `mail.delivery`, `view.rendering` | -| POST | `/auth/password/verify-otp` | `throttle:10,1` | | -| POST | `/auth/password/reset` | `throttle:10,1` | `mail.delivery`, `view.rendering` | - -A project may veto any of these without forking the plugin, via `proj.json`: - -```jsonc -{ "routePolicy": { "disable": ["POST /auth/mobile/register", "auth.identity"] } } -``` - -### Controller ergonomics - -- **`InteractsWithAuth`** — `$this->guard()`, `$this->identity()`, `$this->authId()`, `$this->tokenCan('write')`. -- **`InteractsWithAuthManager`** — `$this->auth('api')->user()`, `$this->authUser()`, `$this->authManager()` (route must `requires: ["auth.identity"]`). - -```php -final class ReportController extends ApiController { - use InteractsWithAuth; - public function export(): Response { - return $this->tokenCan('reports:export') // hierarchical - ? Response::json(['ok' => true]) : Response::forbidden(); - } -} -``` - -### Exceptions - -| Exception | HTTP | When | -|---|---|---| -| `AuthenticationException` | 401 | no/invalid credential (carries guards tried) | -| `AuthorizationException` | 403 | denied; `asNotFound()` masks as 404 | -| `MissingScopeException` | 403 | token lacks a scope (`scopes()`) | -| `InvalidAuthTokenException` | 401 | `::different()/expired()/revoked()` | -| `InvalidRefreshTokenException` | 401 | `::invalid()/reuseDetected()` | - -Security layers never throw — these are for the service/controller layers. - -### CLI - -| Command | Does | -|---|---| -| `auth:tokens:prune` | delete expired personal access tokens | - -> **Known limitation:** `auth:tokens:prune` still resolves the ConnectionManager -> default (central), but `personal_access_tokens` is tenant-scoped — so it targets -> a table central does not have. It needs to iterate the tenant registry and run -> once per tenant connection. Treat it as inoperative until then. - -### Rules - -**Do** — verify in SecurityLayers, issue in `AuthService` (never mix) · pin a -single JWT algo · PATs store only the hash, plaintext once · session login *after* -verify, rotate the id · remember-me/refresh: hash only, rotate on use, family -reuse detection · treat scopes hierarchically · revoke every credential on a -password reset. - -**Don't** — a SecurityLayer that throws · trust a `tnt` claim as authorization -(routing hint only) · re-check tenant seat on refresh (it's at tenant-select) · -unserialize a recaller · confuse `personal_access_tokens` (user keys) with -`oauth_clients` (apps) · `getenv()` for a `JWT_*`/`AUTH_*` value · pin an auth -repository to the central connection · back the password-reset flow with a -per-process in-memory `CachePort`. - ---- - -*OAuth 2.1 / OIDC authorization-server flows live in the `Plugins\OAuth2` plugin.* diff --git a/plugins/Auth/Security/JwtAuthLayer.php b/plugins/Auth/Security/JwtAuthLayer.php deleted file mode 100644 index 015e6e8..0000000 --- a/plugins/Auth/Security/JwtAuthLayer.php +++ /dev/null @@ -1,150 +0,0 @@ -withSecurity([ - * new JwtAuthLayer(secret: env('JWT_SECRET'), algo: 'HS256'), - * ]) - * - * Behaviour: - * - No Authorization header -> allow as guest (public routes still work) - * - Valid Bearer token -> allow with a resolved Identity - * - Malformed / invalid / expired -> deny(401) - * - * Never throws — always returns a SecurityVerdict (GDA security rule). - */ -final class JwtAuthLayer implements SecurityLayerContract -{ - /** - * @param string $secret HMAC secret (HS) or PEM public key (RS / ES). - * @param string $algo Signing algorithm to accept (single algo — never trust the header `alg`). - * @param string|null $issuer When set, the `iss` claim MUST equal this value. - * @param string|null $audience When set, the `aud` claim MUST contain this value. - * @param int $leeway Clock-skew tolerance in seconds for exp/iat/nbf. - * @param CachePort|null $revocations When set, the `jti` claim is checked against - * a deny-list so a token can be revoked before its natural expiry. - */ - public function __construct( - private readonly string $secret, - private readonly string $algo = 'HS256', - private readonly ?string $issuer = null, - private readonly ?string $audience = null, - private readonly int $leeway = 0, - private readonly ?CachePort $revocations = null, - ) { - } - - /** Deny-list cache key for a revoked token id. */ - public static function revocationKey(string $jti): string - { - return 'auth:jwt:revoked:' . $jti; - } - - public function check(Request $request): SecurityVerdict - { - $header = $request->header('Authorization') ?? ''; - if ($header === '' || !str_starts_with($header, 'Bearer ')) { - // Anonymous request — let downstream authorization decide. - return SecurityVerdict::allow($request); - } - - $token = trim(substr($header, 7)); - if ($token === '' || $this->secret === '') { - return SecurityVerdict::deny(401, 'Invalid or missing authentication token.'); - } - - // Honour configured clock-skew tolerance for exp/iat/nbf checks (the JWT - // library reads this static at decode time). - if ($this->leeway > 0) { - JWT::$leeway = $this->leeway; - } - - try { - // Pin to a SINGLE algorithm — never let the token's own `alg` header - // pick the verifier (prevents alg-confusion / HS-vs-RS downgrade). - $claims = (array) JWT::decode($token, new Key($this->secret, $this->algo)); - } catch (\Throwable) { - return SecurityVerdict::deny(401, 'Authentication token is invalid or expired.'); - } - - // Issuer / audience binding — reject tokens minted for another service or - // tenant boundary even if the signature is valid. - if ($this->issuer !== null && ($claims['iss'] ?? null) !== $this->issuer) { - return SecurityVerdict::deny(401, 'Authentication token issuer is not trusted.'); - } - if ($this->audience !== null && !$this->audienceMatches($claims['aud'] ?? null)) { - return SecurityVerdict::deny(401, 'Authentication token audience is not accepted.'); - } - - // Revocation deny-list — a logged-out / compromised token is rejected - // even though its signature and expiry are still valid. Fail OPEN on a - // cache outage (the token is otherwise cryptographically valid) rather - // than locking every user out when the cache is unreachable. - $jti = (string) ($claims['jti'] ?? ''); - if ($this->revocations !== null && $jti !== '') { - try { - if ($this->revocations->has(self::revocationKey($jti))) { - return SecurityVerdict::deny(401, 'Authentication token has been revoked.'); - } - } catch (\Throwable) { - // Cache unavailable — proceed on the valid signature. - } - } - - // Tenant context rides on the signed `tnt` claim (legacy `tenant` - // accepted for BC). Empty = UNSCOPED: the request keeps the central - // connection (login, tenant picker, public pages). A non-empty tenant is - // routed to its isolated DB by plugins/Tenancy's TenantContextStage, - // which re-checks membership so a revoked seat loses access before expiry. - $tenant = (string) ($claims['tnt'] ?? $claims['tenant'] ?? ''); - - $identity = new Identity( - userId: (string) ($claims['sub'] ?? ''), - tenantId: $tenant, - roles: array_values((array) ($claims['roles'] ?? [])), - permissions: array_values((array) ($claims['permissions'] ?? [])), - tokenType: 'jwt', - // Display-identity claims minted by AuthService::issueJwt() (OIDC - // names). `name` is first + last from the tenant user_profiles table, - // present only on tenant-scoped tokens. - username: (string) ($claims['preferred_username'] ?? ''), - email: (string) ($claims['email'] ?? ''), - fullName: (string) ($claims['name'] ?? ''), - ); - - return SecurityVerdict::allow($request->withIdentity($identity)); - } - - /** `aud` may be a single string or a list; accept when our audience is present. */ - private function audienceMatches(mixed $aud): bool - { - if (is_string($aud)) { - return hash_equals($this->audience ?? '', $aud); - } - if (is_array($aud)) { - foreach ($aud as $candidate) { - if (is_string($candidate) && hash_equals($this->audience ?? '', $candidate)) { - return true; - } - } - } - - return false; - } -} diff --git a/plugins/Auth/Security/PersonalAccessTokenLayer.php b/plugins/Auth/Security/PersonalAccessTokenLayer.php deleted file mode 100644 index eb9b58c..0000000 --- a/plugins/Auth/Security/PersonalAccessTokenLayer.php +++ /dev/null @@ -1,77 +0,0 @@ -` tokens by hashing and matching - * against personal_access_tokens. Wire it in a project bootstrap with the - * DatabasePort instance: - * - * ->withSecurity([ ..., new PersonalAccessTokenLayer($databasePortInstance) ]) - * - * No header -> allow as guest. Bad token -> deny(401). Never throws. - */ -final class PersonalAccessTokenLayer implements SecurityLayerContract -{ - private readonly PersonalAccessTokenRepository $tokens; - - public function __construct(DatabasePort $db, string $table = 'personal_access_tokens') - { - $this->tokens = new PersonalAccessTokenRepository($db, $table); - } - - public function check(Request $request): SecurityVerdict - { - $header = $request->header('Authorization') ?? ''; - if ($header === '' || !str_starts_with($header, 'Bearer ')) { - return SecurityVerdict::allow($request); - } - - $token = trim(substr($header, 7)); - // A PAT looks like "<32hex id>.<64hex secret>". Skip if it doesn't. - if (!str_contains($token, '.')) { - return SecurityVerdict::allow($request); - } - - try { - $record = $this->tokens->findByHash(hash('sha256', $token)); - } catch (\Throwable) { - return SecurityVerdict::deny(401, 'Could not verify access token.'); - } - - if ($record === null) { - return SecurityVerdict::deny(401, 'Access token is invalid, revoked, or expired.'); - } - - // Record last use — best-effort, never blocks the request. - $this->tokens->touch($record['id']); - - // Empty tenant = unscoped (central connection), consistent with the JWT - // layer and TenantContextStage. A PAT is a control-plane credential; it - // does not silently bind to a tenant DB — so fullName stays empty (it - // lives in the tenant user_profiles table). The repository's findByHash - // joins the owner's username/email onto the record (all SQL stays there). - $identity = new Identity( - userId: $record['user_id'], - tenantId: '', - roles: [], - permissions: $record['abilities'], - tokenType: 'api_key', - username: (string) ($record['username'] ?? ''), - email: (string) ($record['email'] ?? ''), - ); - - return SecurityVerdict::allow($request->withIdentity($identity)); - } -} diff --git a/plugins/Auth/Support/Token.php b/plugins/Auth/Support/Token.php deleted file mode 100644 index 33aba67..0000000 --- a/plugins/Auth/Support/Token.php +++ /dev/null @@ -1,44 +0,0 @@ -/config/auth.php wins over the plugin - * default. Supports dotted key access. - * - * auth_config(); // full array - * auth_config('defaults.guard'); // 'web' - * auth_config('guards.api'); // ['driver' => 'token', 'provider' => 'users'] - * - * @return mixed the whole config array, or a single (dotted) key's value - */ - function auth_config(?string $key = null, mixed $default = null): mixed - { - /** @var array|null $config */ - static $config = null; - - if ($config === null) { - $projectFile = Paths::config('auth.php'); - $pluginFile = __DIR__ . '/../config/auth.php'; - - $file = is_file($projectFile) ? $projectFile : $pluginFile; - $loaded = require $file; - $config = is_array($loaded) ? $loaded : []; - } - - if ($key === null) { - return $config; - } - - $value = $config; - foreach (explode('.', $key) as $segment) { - if (!is_array($value) || !array_key_exists($segment, $value)) { - return $default; - } - $value = $value[$segment]; - } - - return $value; - } -} diff --git a/plugins/Auth/config/auth.php b/plugins/Auth/config/auth.php deleted file mode 100644 index 3803d39..0000000 --- a/plugins/Auth/config/auth.php +++ /dev/null @@ -1,80 +0,0 @@ -/config/auth.php (project override — copy this file there) - * 2. plugins/Auth/config/auth.php (this file — framework default) - * - * Read it with the auth_config() helper: - * auth_config(); // full array - * auth_config('defaults.guard'); // 'web' - */ -return [ - - /* - |-------------------------------------------------------------------------- - | Defaults - |-------------------------------------------------------------------------- - | The guard + provider used when AuthManager::guard()/provider() is called - | with no explicit name. - */ - 'defaults' => [ - 'guard' => env('AUTH_GUARD', 'web'), - 'provider' => env('AUTH_PROVIDER', 'users'), - ], - - /* - |-------------------------------------------------------------------------- - | Guards - |-------------------------------------------------------------------------- - | Each guard binds a driver (session/jwt/token/request — filesystem-scanned - | from Infrastructure/Auth/Drivers) to a named provider below. - | - web: stateful browser session (+ remember-me via SessionAuthStage) - | - api: personal access tokens (Bearer .) - | - jwt: stateless Bearer JWTs - | - request: credential-agnostic — whatever the SecurityGateway attached - */ - 'guards' => [ - 'web' => ['driver' => 'session', 'provider' => 'users'], - 'api' => ['driver' => 'token', 'provider' => 'users'], - 'jwt' => ['driver' => 'jwt', 'provider' => 'users'], - 'request' => ['driver' => 'request', 'provider' => 'users'], - ], - - /* - |-------------------------------------------------------------------------- - | Providers - |-------------------------------------------------------------------------- - | Named user sources. 'model' = ModelUserProvider over UserServiceContract - | (the central identity store). Add more to back a guard with another store. - */ - 'providers' => [ - 'users' => ['driver' => 'model'], - ], - - /* - |-------------------------------------------------------------------------- - | Stateful session security (old __DEV__ flow) - |-------------------------------------------------------------------------- - | Every web login is bound to a device fingerprint and registered in the - | central `auth_sessions` table. Requests that can't reproduce the - | fingerprint — or whose server-side row was revoked/expired — lose the - | session immediately (see DeviceSessionService). - | - | ttl_days absolute device-session lifetime - | refresh_days rolling window: inside the last N days the expiry slides - | forward a full TTL on activity - | client_fingerprint_header - | optional client-supplied fingerprint (e.g. FingerprintJS); - | falls back to sha256(ip|user-agent) - */ - 'session' => [ - 'ttl_days' => (int) (env('AUTH_SESSION_TTL') ?: 30), - 'refresh_days' => (int) (env('AUTH_SESSION_REFRESH') ?: 7), - 'client_fingerprint_header' => env('AUTH_FINGERPRINT_HEADER') ?: 'X-Client-Fingerprint', - ], -]; diff --git a/plugins/Auth/database/migrations/.gitkeep b/plugins/Auth/database/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/Auth/database/tenant-template/2026_06_05_000001_create_personal_access_tokens_table.php b/plugins/Auth/database/tenant-template/2026_06_05_000001_create_personal_access_tokens_table.php deleted file mode 100644 index 3f99612..0000000 --- a/plugins/Auth/database/tenant-template/2026_06_05_000001_create_personal_access_tokens_table.php +++ /dev/null @@ -1,27 +0,0 @@ -create('personal_access_tokens', static function ($t) { - $t->string('id', 64)->primary(); - $t->string('user_id', 64); - $t->string('name', 255); - $t->string('token_hash', 64)->unique(); - $t->timestamp('last_used_at')->nullable(); - $t->timestamp('created_at')->nullable(); - - $t->index(['user_id']); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('personal_access_tokens'); - } -}; diff --git a/plugins/Auth/database/tenant-template/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php b/plugins/Auth/database/tenant-template/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php deleted file mode 100644 index 1ce9836..0000000 --- a/plugins/Auth/database/tenant-template/2026_06_27_000002_add_expiry_and_abilities_to_personal_access_tokens.php +++ /dev/null @@ -1,45 +0,0 @@ -hasTable('personal_access_tokens')) { - return; - } - - $schema->table('personal_access_tokens', static function ($t) use ($schema) { - if (!$schema->hasColumn('personal_access_tokens', 'expires_at')) { - $t->timestamp('expires_at')->nullable(); - } - if (!$schema->hasColumn('personal_access_tokens', 'abilities')) { - $t->text('abilities')->nullable(); - } - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - if (!$schema->hasTable('personal_access_tokens')) { - return; - } - - $schema->table('personal_access_tokens', static function ($t) use ($schema) { - if ($schema->hasColumn('personal_access_tokens', 'expires_at')) { - $t->dropColumn('expires_at'); - } - if ($schema->hasColumn('personal_access_tokens', 'abilities')) { - $t->dropColumn('abilities'); - } - }); - } -}; diff --git a/plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php b/plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php deleted file mode 100644 index c62a724..0000000 --- a/plugins/Auth/database/tenant-template/2026_07_04_000002_create_refresh_tokens_table.php +++ /dev/null @@ -1,60 +0,0 @@ -hasTable('refresh_tokens')) { - return; // pre-existing (e.g. migrated under the old Tenancy owner) - } - - $schema->create('refresh_tokens', static function ($t) { - $t->id(); - $t->char('token_id', 31); - $t->char('family_id', 31)->comment('rotation lineage for reuse detection'); - $t->char('user_id', 31) - ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); - $t->char('token_hash', 64)->comment('SHA-256 of the refresh token — never store raw'); - $t->char('tenant_id', 31)->nullable()->comment('scope hint for the tnt claim; not re-verified'); - $t->string('device', 191)->nullable()->comment('UA / device label'); - $t->string('ip', 45)->nullable(); - $t->timestamp('expires_at'); - $t->timestamp('revoked_at')->nullable(); - $t->timestamp('last_used_at')->nullable(); - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - - $t->unique(['token_id'], 'uniq_token_id'); - $t->unique(['token_hash'], 'uniq_token_hash'); - $t->index(['user_id', 'revoked_at'], 'idx_user_active'); - $t->index(['family_id'], 'idx_family'); - - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('refresh_tokens'); - } -}; diff --git a/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php b/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php deleted file mode 100644 index aed4cf0..0000000 --- a/plugins/Auth/database/tenant-template/2026_07_12_000001_create_auth_sessions_table.php +++ /dev/null @@ -1,55 +0,0 @@ -hasTable('auth_sessions')) { - return; - } - - $schema->create('auth_sessions', static function ($t) { - $t->id(); - $t->char('session_id', 32)->comment('public id (list/revoke API) — not the token'); - $t->char('user_id', 31) - ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); - $t->char('token_hash', 64)->comment('SHA-256 of the session token — never store raw'); - $t->char('fingerprint', 64)->nullable()->comment('SHA-256 device fingerprint captured at login'); - $t->string('ip', 45)->nullable(); - $t->string('user_agent', 191)->nullable(); - $t->timestamp('last_seen_at')->nullable(); - $t->timestamp('expires_at'); - $t->timestamp('revoked_at')->nullable(); - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - - $t->unique(['session_id'], 'uniq_session_id'); - $t->unique(['token_hash'], 'uniq_token_hash'); - $t->index(['user_id', 'revoked_at'], 'idx_user_active'); - - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('auth_sessions'); - } -}; diff --git a/plugins/Auth/module.json b/plugins/Auth/module.json deleted file mode 100644 index 0de359e..0000000 --- a/plugins/Auth/module.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "auth", - "version": "1.0.0", - "solves": "auth.identity", - "type": "module", - - "requires": ["database.management", "crypto.services", "user.management", "authorization.policy"], - "exposes": [ - "Plugins\\Auth\\API\\Contracts\\AuthServiceContract", - "Plugins\\Auth\\API\\Contracts\\RefreshTokenServiceContract" - ], - - "routes": [ - { "method": "POST", "path": "/auth/login", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@login", "filters": ["throttle:10,1"] }, - { "method": "POST", "path": "/auth/logout", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@logout" }, - { "method": "GET", "path": "/auth/me", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@me" }, - - { "method": "GET", "path": "/auth/sessions", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@sessions", "filters": ["auth"] }, - { "method": "DELETE", "path": "/auth/sessions/{id}", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@revokeSession", "filters": ["auth"] }, - { "method": "POST", "path": "/auth/logout-other-devices", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\SessionAuthController@logoutOtherDevices", "filters": ["auth", "throttle:5,1"] }, - - { "method": "GET", "path": "/auth/tokens", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PersonalAccessTokenController@index", "filters": ["auth"] }, - { "method": "POST", "path": "/auth/tokens", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PersonalAccessTokenController@store", "filters": ["auth", "throttle:20,1"] }, - { "method": "DELETE", "path": "/auth/tokens/{id}", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PersonalAccessTokenController@destroy", "filters": ["auth"] }, - - { "method": "POST", "path": "/auth/token/refresh", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\TransientTokenController@refresh", "filters": ["auth"] }, - - { "method": "POST", "path": "/auth/refresh", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\AuthTokenController@refresh", "filters": ["throttle:30,1"] }, - { "method": "POST", "path": "/auth/refresh/logout", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\AuthTokenController@logout" }, - - - { "method": "POST", "path": "/auth/password/forgot", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PasswordResetController@forgot", "filters": ["throttle:5,1"], "requires": ["mail.delivery", "view.rendering"] }, - { "method": "POST", "path": "/auth/password/verify-otp", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PasswordResetController@verifyOtp", "filters": ["throttle:10,1"] }, - { "method": "POST", "path": "/auth/password/reset", "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\PasswordResetController@reset", "filters": ["throttle:10,1"], "requires": ["mail.delivery", "view.rendering"] } - ], - - "views": { "path": "resources/views", "namespace": "auth", "global": false }, - "emits": [], - "listens": [], - - "config": [ - { "key": "JWT_SECRET", "type": "string", "required": false }, - { "key": "JWT_ALGO", "type": "string", "required": false }, - { "key": "JWT_ISSUER", "type": "string", "required": false }, - { "key": "JWT_AUDIENCE", "type": "string", "required": false }, - { "key": "JWT_PRIVATE_KEY", "type": "string", "required": false }, - { "key": "JWT_PRIVATE_KEY_FILE", "type": "string", "required": false }, - { "key": "JWT_KID", "type": "string", "required": false }, - { "key": "AUTH_PAT_TABLE", "type": "string", "required": false }, - { "key": "AUTH_REFRESH_TTL", "type": "int", "required": false }, - { "key": "AUTH_REFRESH_ACCESS_TTL", "type": "int", "required": false }, - { "key": "AUTH_SESSION_TTL", "type": "int", "required": false }, - { "key": "AUTH_SESSION_REFRESH", "type": "int", "required": false }, - { "key": "AUTH_FINGERPRINT_HEADER", "type": "string", "required": false }, - { "key": "AUTH_MOBILE_ACCESS_TTL", "type": "int", "required": false }, - { "key": "AUTH_MOBILE_AUTOVERIFY", "type": "string", "required": false }, - { "key": "AUTH_OTP_TTL", "type": "int", "required": false }, - { "key": "AUTH_GUARD", "type": "string", "required": false }, - { "key": "AUTH_PROVIDER", "type": "string", "required": false } - ] -} diff --git a/plugins/Auth/resources/views/password-changed.php b/plugins/Auth/resources/views/password-changed.php deleted file mode 100644 index 23569fe..0000000 --- a/plugins/Auth/resources/views/password-changed.php +++ /dev/null @@ -1,49 +0,0 @@ - - - - - -

- - diff --git a/plugins/Auth/resources/views/password-otp.php b/plugins/Auth/resources/views/password-otp.php deleted file mode 100644 index 262531e..0000000 --- a/plugins/Auth/resources/views/password-otp.php +++ /dev/null @@ -1,29 +0,0 @@ - - - - - -
-
-

Password Reset Code

-

- Use the code below to reset your password. It expires in minutes. -

-
- -
-

- If you didn't request a password reset, you can safely ignore this email. - Your password will not be changed. -

-
-
- - diff --git a/plugins/Authorization/API/Contracts/AuthorizationServiceContract.php b/plugins/Authorization/API/Contracts/AuthorizationServiceContract.php deleted file mode 100644 index 5b3d0fb..0000000 --- a/plugins/Authorization/API/Contracts/AuthorizationServiceContract.php +++ /dev/null @@ -1,63 +0,0 @@ - - */ - public function rolesOf(string $user, ?string $domain = null): array; - - /** - * Effective permissions for a user — their own grants PLUS everything - * inherited through the role hierarchy, flattened to "object:action" - * strings (the platform Identity->permissions convention). - * - * @return list - */ - public function permissionsOf(string $user, ?string $domain = null): array; - - /** - * Add a permission policy rule: subject can do action on object. - */ - public function grant(string $subject, string $object, string $action, string ...$extra): bool; - - /** - * Remove a permission policy rule. - */ - public function revoke(string $subject, string $object, string $action, string ...$extra): bool; -} diff --git a/plugins/Authorization/Application/Services/AuthorizationService.php b/plugins/Authorization/Application/Services/AuthorizationService.php deleted file mode 100644 index 108d905..0000000 --- a/plugins/Authorization/Application/Services/AuthorizationService.php +++ /dev/null @@ -1,95 +0,0 @@ -enforcer->enforce($subject, $object, $action, ...$extra); - } catch (\Throwable $e) { - throw new ServiceException( - 'authorization.enforce.failed', - layer: 'service.authorization', - context: ['subject' => $subject, 'object' => $object, 'action' => $action], - previous: $e, - ); - } - } - - public function denies(string $subject, string $object, string $action, string ...$extra): bool - { - return !$this->allows($subject, $object, $action, ...$extra); - } - - public function assignRole(string $user, string $role, ?string $domain = null): bool - { - return $domain === null - ? $this->enforcer->addRoleForUser($user, $role) - : $this->enforcer->addRoleForUserInDomain($user, $role, $domain); - } - - public function revokeRole(string $user, string $role, ?string $domain = null): bool - { - return $domain === null - ? $this->enforcer->deleteRoleForUser($user, $role) - : $this->enforcer->deleteRoleForUserInDomain($user, $role, $domain); - } - - /** @return list */ - public function rolesOf(string $user, ?string $domain = null): array - { - return $domain === null - ? $this->enforcer->getRolesForUser($user) - : $this->enforcer->getRolesForUserInDomain($user, $domain); - } - - /** @return list effective (own + role-inherited) "object:action" grants */ - public function permissionsOf(string $user, ?string $domain = null): array - { - $rules = $domain === null - ? $this->enforcer->getImplicitPermissionsForUser($user) - : $this->enforcer->getImplicitPermissionsForUser($user, $domain); - - $permissions = []; - foreach ($rules as $rule) { - // Rule shape: [sub, obj, act] (+ optional extras) — flatten to obj:act. - $object = (string) ($rule[1] ?? ''); - $action = (string) ($rule[2] ?? ''); - if ($object !== '' && $action !== '') { - $permissions[$object . ':' . $action] = true; - } - } - - return array_keys($permissions); - } - - public function grant(string $subject, string $object, string $action, string ...$extra): bool - { - return $this->enforcer->addPolicy($subject, $object, $action, ...$extra); - } - - public function revoke(string $subject, string $object, string $action, string ...$extra): bool - { - return $this->enforcer->removePolicy($subject, $object, $action, ...$extra); - } -} diff --git a/plugins/Authorization/Engine/CachedEnforcer.php b/plugins/Authorization/Engine/CachedEnforcer.php deleted file mode 100644 index edbc0cb..0000000 --- a/plugins/Authorization/Engine/CachedEnforcer.php +++ /dev/null @@ -1,261 +0,0 @@ -enableCache = true; - $this->cache = new ArrayAdapter(); - $this->expireTime = null; - parent::__construct($model, $adapter, $logger, $enableLog); - } - - /** - * Enforce decides whether a "subject" can access a "object" with the operation "action", input parameters are usually: (sub, obj, act). - * If rvals is not string , ingore the cache. - * - * @param mixed ...$rvals - * - * @return bool - * - * @throws Exceptions\CasbinException - */ - public function enforce(...$rvals): bool - { - if (!$this->enableCache) { - return parent::enforce(...$rvals); - } - - $key = $this->getKey(...$rvals); - $res = $this->getCachedResult($key); - if (!is_null($res)) { - return $res; - } - - $value = parent::enforce(...$rvals); - $this->setCachedResult($key, $value); - return $value; - } - - /** - * Determines whether to enable cache on Enforce(). When enableCache is enabled, cached result (true | false) will be returned for previous decisions. - * - * @param bool $enableCache - * - * @return void - */ - public function enableCache(bool $enableCache = true): void - { - $this->enableCache = $enableCache; - } - - - /** - * Sets the cache adapter for the enforcer. - * - * - * @param CacheItemPoolInterface $cache - * - * @return void - */ - public function setCache(CacheItemPoolInterface $cache): void - { - $this->cache = $cache; - } - - - /** - * Sets the expire time for the cache in seconds. If the value is null, the cache will never expire. - * - * @param int|null $expireTime - * - * @return void - */ - public function setExpireTime(int|null $expireTime): void - { - $this->expireTime = $expireTime; - } - - /** - * Invalidates the cache. - */ - public function invalidateCache(): void - { - $this->cache->clear(); - } - - /** - * Reloads the policy from file/database. - */ - public function loadPolicy(): void - { - if ($this->enableCache) { - $this->cache->clear(); - } - - parent::loadPolicy(); - } - - /** - * Removes an authorization rule from the current policy. - * - * @param mixed ...$params - * - * @return bool - */ - public function removePolicy(...$params): bool - { - if ($this->enableCache) { - $key = $this->getKey(...$params); - $this->cache->deleteItem($key); - } - - return parent::removePolicy(...$params); - } - - /** - * Removes an authorization rules from the current policy. - * - * @param array $rules - * - * @return bool - */ - public function removePolicies(array $rules): bool - { - if ($this->enableCache) { - foreach ($rules as $rule) { - $key = $this->getKey(...$rule); - $this->cache->deleteItem($key); - } - } - - return parent::removePolicies($rules); - } - - /** - * Clears all policy. - */ - public function clearPolicy(): void - { - if ($this->enableCache) { - if (!$this->cache->clear()) { - $this->logger->logError(new CasbinException('clear cache failed')); - } - } - - parent::clearPolicy(); - } - - /** - * Gets the cached result from the cache by key. - * - * If the key does not exist in the cache, it returns null. - * - * @param string $key - * - * @return bool|null - */ - public function getCachedResult(string $key): bool|null - { - $value = $this->cache->getItem($key)->get(); - return $value; - } - - /** - * Sets the cached result to the cache by key. - * - * @param string $key - * @param bool $value - * - * @return void - */ - public function setCachedResult(string $key, bool $value): void - { - $item = $this->cache->getItem($key); - $item->set($value); - $item->expiresAfter($this->expireTime); - $this->cache->save($item); - } - - /** - * Gets the cache key by combining the input parameters. - * - * @param mixed ...$rvals - * - * @return string - */ - public function getCacheKey(...$rvals): string - { - $key = ''; - foreach ($rvals as $rval) { - if (is_string($rval)) { - $key .= $rval; - } elseif ($rval instanceof CacheableParam) { - $key .= $rval->getCacheKey(); - } else { - return ''; - } - $key .= '$$'; - } - - return $key; - } - - /** - * Gets the cache key by combining the input parameters. - * - * @param mixed ...$rvals - * - * @return string - */ - private function getKey(...$rvals): string - { - return $this->getCacheKey(...$rvals); - } -} diff --git a/plugins/Authorization/Engine/Config.php b/plugins/Authorization/Engine/Config.php deleted file mode 100644 index 6a048ca..0000000 --- a/plugins/Authorization/Engine/Config.php +++ /dev/null @@ -1,264 +0,0 @@ -> - */ - public array $data = []; - - /** - * Create an empty configuration representation from file. - * - * @param string $confName - * - * @return ConfigInterface - * @throws CasbinException - */ - public static function newConfig(string $confName): ConfigInterface - { - $c = new static(); - $c->parse($confName); - - return $c; - } - - /** - * Create an empty configuration representation from text. - * - * @param string $text - * - * @return ConfigInterface - * @throws CasbinException - */ - public static function newConfigFromText(string $text): ConfigInterface - { - $c = new Config(); - $c->parseBuffer($text); - - return $c; - } - - /** - * Adds a new section->key:value to the configuration. - * - * @param string $section - * @param string $option - * @param string $value - * - * @return bool - */ - public function addConfig(string $section, string $option, string $value): bool - { - if (empty($section)) { - $section = self::DEFAULT_SECTION; - } - - if (!isset($this->data[$section])) { - $this->data[$section] = []; - } - - $this->data[$section][$option] = $value; - - return true; - } - - /** - * @param string $fname - * - * @return bool - * - * @throws CasbinException - */ - private function parse(string $fname): bool - { - $buf = file_get_contents($fname); - - return $buf === false ? false : $this->parseBuffer($buf); - } - - /** - * @param string $buf - * - * @return bool - * - * @throws CasbinException - */ - private function parseBuffer(string $buf): bool - { - $section = ''; - $lineNum = 0; - $buffer = ''; - $canWrite = null; - - $buf = preg_replace('/[\r\n]+/', PHP_EOL, $buf); - $buf = explode(PHP_EOL, $buf ?? ''); - - $len = count($buf); - - for ($i = 0; $i <= $len; ++$i) { - if ($canWrite) { - $this->write($section, $lineNum, $buffer); - $canWrite = false; - } - - ++$lineNum; - $line = $buf[$i] ?? ''; - if ($i == $len) { - if (\strlen($buffer) > 0) { - $this->write($section, $lineNum, $buffer); - } - - break; - } - $line = trim($line); - - if ('' == $line || self::DEFAULT_COMMENT == substr($line, 0, 1) || self::DEFAULT_COMMENT_SEM == substr($line, 0, 1)) { - $canWrite = true; - - continue; - } elseif ('[' == substr($line, 0, 1) && ']' == substr($line, -1)) { - if (\strlen($buffer) > 0) { - $this->write($section, $lineNum, $buffer); - $canWrite = false; - } - $section = substr($line, 1, -1); - } else { - $p = ''; - if (self::DEFAULT_MULTI_LINE_SEPARATOR == substr($line, -1)) { - $p = trim(substr($line, 0, -1)); - } else { - $p = $line; - $canWrite = true; - } - $buffer .= $p; - } - } - - return true; - } - - /** - * @param string $section - * @param int $lineNum - * @param string $b - * - * @throws CasbinException - */ - private function write(string $section, int $lineNum, string &$b): void - { - if (\strlen($b) <= 0) { - return; - } - - $optionVal = explode('=', $b, 2); - - if (2 != count($optionVal)) { - throw new CasbinException(sprintf('parse the content error : line %d , %s = ?', $lineNum, current($optionVal))); - } - - $option = trim($optionVal[0]); - $value = trim($optionVal[1]); - - $this->addConfig($section, $option, $value); - - $b = ''; - } - - /** - * Lookups up the value using the provided key and converts the value to a string. - * - * @param string $key - * - * @return string - */ - public function getString(string $key): string - { - return $this->get($key); - } - - /** - * Lookups up the value using the provided key and converts the value to an array of string - * by splitting the string by comma. - * - * @param string $key - * - * @return array - */ - public function getStrings(string $key): array - { - $v = $this->get($key); - if ('' == $v) { - return []; - } - - return explode(',', $v); - } - - /** - * Sets the value for the specific key in the Config. - * - * @param string $key - * @param string $value - * - * @throws CasbinException - */ - public function set(string $key, string $value): void - { - if (0 == \strlen($key)) { - throw new CasbinException('key is empty'); - } - - $section = ''; - - $keys = explode('::', strtolower($key)); - if (count($keys) >= 2) { - $section = $keys[0]; - $option = $keys[1]; - } else { - $option = $keys[0]; - } - $this->addConfig($section, $option, $value); - } - - /** - * section.key or key. - * - * @param string $key - * - * @return string - */ - public function get(string $key): string - { - $keys = explode('::', $key); - if (count($keys) >= 2) { - $section = $keys[0]; - $option = $keys[1]; - } else { - $section = self::DEFAULT_SECTION; - $option = $keys[0]; - } - - return $this->data[$section][$option] ?? ''; - } -} diff --git a/plugins/Authorization/Engine/Constants.php b/plugins/Authorization/Engine/Constants.php deleted file mode 100644 index fa560c4..0000000 --- a/plugins/Authorization/Engine/Constants.php +++ /dev/null @@ -1,26 +0,0 @@ - - */ - protected array $rmMap; - - /** - * CondRmMap. - * - * @var array - */ - protected array $condRmMap; - - /** - * $enabled. - * - * @var bool - */ - protected bool $enabled; - - /** - * $autoSave. - * - * @var bool - */ - protected bool $autoSave; - - /** - * $autoBuildRoleLinks. - * - * @var bool - */ - protected bool $autoBuildRoleLinks; - - /** - * $autoNotifyWatcher. - * - * @var bool - */ - protected bool $autoNotifyWatcher; - - /** - * $logger. - * - * @var Logger - */ - protected Logger $logger; - - /** - * Enforcer constructor. - * Creates an enforcer via file or DB. - * File: - * $e = new Enforcer("path/to/basic_model.conf", "path/to/basic_policy.csv") - * MySQL DB: - * $a = DatabaseAdapter::newAdapter([ - * 'type' => 'mysql', // mysql,pgsql,sqlite,sqlsrv - * 'hostname' => '127.0.0.1', - * 'database' => 'test', - * 'username' => 'root', - * 'password' => '123456', - * 'hostport' => '3306', - * ]); - * $e = new Enforcer("path/to/basic_model.conf", $a). - * - * @param string|Model|null $model - * @param string|Adapter|null $adapter - * @param Logger|null $logger - * @param bool|null $enableLog - * - * @throws CasbinException - */ - public function __construct(string|Model|null $model = null, string|Adapter|null $adapter = null, ?Logger $logger = null, ?bool $enableLog = null) - { - $this->logger = $logger ?? new DefaultLogger(); - - if (!is_null($enableLog)) { - $this->enableLog($enableLog); - } - - if (is_null($model) && is_null($adapter)) { - return; - } - if (is_string($model)) { - if (is_string($adapter) || is_null($adapter)) { - $this->initWithFile($model, $adapter ?? ''); - } else if ($adapter instanceof Adapter) { - $this->initWithAdapter($model, $adapter); - } - } else if ($model instanceof Model) { - if ($adapter instanceof Adapter || is_null($adapter)) { - $this->initWithModelAndAdapter($model, $adapter); - } else { - throw new CasbinException('Invalid parameters for enforcer.'); - } - } else { - throw new CasbinException('Invalid parameters for enforcer.'); - } - } - - /** - * Initializes an enforcer with a model file and a policy file. - * - * @param string $modelPath - * @param string $policyPath - * - * @throws CasbinException - */ - public function initWithFile(string $modelPath, string $policyPath): void - { - $adapter = new FileAdapter($policyPath); - $this->initWithAdapter($modelPath, $adapter); - } - - /** - * Initializes an enforcer with a database adapter. - * - * @param string $modelPath - * @param Adapter $adapter - * - * @throws CasbinException - */ - public function initWithAdapter(string $modelPath, Adapter $adapter): void - { - $m = Model::newModelFromFile($modelPath); - $this->initWithModelAndAdapter($m, $adapter); - - $this->modelPath = $modelPath; - } - - /** - * InitWithModelAndAdapter initializes an enforcer with a model and a database adapter. - * - * @param Model $m - * @param Adapter|null $adapter - */ - public function initWithModelAndAdapter(Model $m, ?Adapter $adapter): void - { - $this->adapter = $adapter; - $this->model = $m; - $this->model->setLogger($this->logger); - $this->model->printModel(); - - $this->fm = Model::loadFunctionMap(); - - $this->initialize(); - - // Do not initialize the full policy when using a filtered adapter - $ok = $this->adapter instanceof FilteredAdapter ? $this->adapter->isFiltered() : false; - - if (!is_null($this->adapter) && !$ok) { - $this->loadPolicy(); - } - } - - /** - * Sets the current logger. - * - * @param Logger $logger - */ - public function setLogger(Logger $logger): void - { - $this->logger = $logger; - $this->model->setLogger($this->logger); - foreach ($this->rmMap as $rm) { - $rm->setLogger($this->logger); - } - foreach ($this->condRmMap as $rm) { - $rm->setLogger($this->logger); - } - } - - /** - * Initializes an enforcer with a database adapter. - */ - protected function initialize(): void - { - $this->rmMap = []; - $this->condRmMap = []; - $this->eft = new DefaultEffector(); - $this->watcher = null; - - $this->enabled = true; - $this->autoSave = true; - $this->autoBuildRoleLinks = true; - $this->autoNotifyWatcher = true; - $this->initRmMap(); - } - - /** - * Reloads the model from the model CONF file. - * Because the policy is attached to a model, so the policy is invalidated and needs to be reloaded by calling LoadPolicy(). - * - * @throws CasbinException - */ - public function loadModel(): void - { - $this->model = Model::newModelFromFile($this->modelPath); - $this->model->printModel(); - $this->fm = Model::loadFunctionMap(); - - $this->initialize(); - } - - /** - * Gets the current model. - * - * @return Model - */ - public function getModel(): Model - { - return $this->model; - } - - /** - * Sets the current model. - * - * @param Model $model - */ - public function setModel(Model $model): void - { - $this->model = $model; - $this->fm = $this->model->loadFunctionMap(); - - $this->initialize(); - } - - /** - * Gets the current adapter. - * - * @return Adapter|null - */ - public function getAdapter(): ?Adapter - { - return $this->adapter; - } - - /** - * Sets the current adapter. - * - * @param Adapter $adapter - */ - public function setAdapter(Adapter $adapter): void - { - $this->adapter = $adapter; - } - - /** - * Sets the current watcher. - * - * @param Watcher $watcher - */ - public function setWatcher(Watcher $watcher): void - { - $this->watcher = $watcher; - $this->watcher->setUpdateCallback(function () { - $this->loadPolicy(); - }); - } - - /** - * Gets the current role manager. - * - * @return RoleManager - */ - public function getRoleManager(): RoleManager - { - return $this->rmMap['g']; - } - - /** - * Gets the current role manager. - * - * @param RoleManager $rm - */ - public function setRoleManager(RoleManager $rm): void - { - $this->rmMap['g'] = $rm; - } - - /** - * Sets the current effector. - * - * @param Effector $eft - */ - public function setEffector(Effector $eft): void - { - $this->eft = $eft; - } - - /** - * Clears all policy. - */ - public function clearPolicy(): void - { - $this->model->clearPolicy(); - } - - /** - * Reloads the policy from file/database. - */ - public function loadPolicy(): void - { - $newModel = $this->loadPolicyFromAdapter($this->model); - if (!is_null($newModel)) { - $this->applyModifiedModel($newModel); - } - } - - /** - * Loads policy from the current adapter. - * - * @param Model $baseModel - * - * @return Model|null - */ - public function loadPolicyFromAdapter(Model $baseModel): ?Model - { - $newModel = clone $baseModel; - $newModel->clearPolicy(); - - try { - $this->adapter?->loadPolicy($newModel); - $newModel->sortPoliciesBySubjectHierarchy(); - $newModel->sortPoliciesByPriority(); - } catch (InvalidFilePathException) { - return null; - } catch (\Throwable $e) { - throw $e; - } - - return $newModel; - } - - /** - * Applies a modified model to the current enforcer. - * - * @param Model $newModel - */ - public function applyModifiedModel(Model $newModel): void - { - $flag = false; - $needToRebuild = false; - - try { - if ($this->autoBuildRoleLinks) { - $needToRebuild = true; - - $this->rebuildRoleLinks($newModel); - $this->rebuildConditionalRoleLinks($newModel); - } - $this->model = $newModel; - } catch (\Throwable $e) { - $flag = true; - throw $e; - } finally { - if ($flag) { - if ($this->autoBuildRoleLinks && $needToRebuild) { - $this->buildRoleLinks(); - } - } - } - } - - /** - * Rebuilds the role inheritance relations based on the new model. - * - * @param Model $newModel - */ - public function rebuildRoleLinks(Model $newModel): void - { - if (count($this->rmMap) !== 0) { - foreach ($this->rmMap as $rm) { - $rm->clear(); - } - - $newModel->buildRoleLinks($this->rmMap); - } - } - - /** - * Rebuilds the conditional role inheritance relations based on the new model. - * - * @param Model $newModel - */ - public function rebuildConditionalRoleLinks(Model $newModel): void - { - if (!empty($this->condRmMap)) { - foreach ($this->condRmMap as $rm) { - $rm->clear(); - } - - $newModel->buildConditionalRoleLinks($this->condRmMap); - } - } - - /** - * Reloads a filtered policy from file/database. - * - * @param mixed $filter - * - * @throws CasbinException - */ - public function _loadFilteredPolicy($filter): void - { - if ($this->adapter instanceof FilteredAdapter) { - $filteredAdapter = $this->adapter; - $filteredAdapter->loadFilteredPolicy($this->model, $filter); - } else { - throw new CasbinException('filtered policies are not supported by this adapter'); - } - - $this->model->sortPoliciesBySubjectHierarchy(); - $this->model->sortPoliciesByPriority(); - $this->initRmMap(); - $this->model->printPolicy(); - if ($this->autoBuildRoleLinks) { - $this->buildRoleLinks(); - } - } - - /** - * Reloads a filtered policy from file/database. - * - * @param mixed $filter - * - * @throws CasbinException - */ - public function loadFilteredPolicy($filter): void - { - $this->model->clearPolicy(); - - $this->_loadFilteredPolicy($filter); - } - - /** - * LoadIncrementalFilteredPolicy append a filtered policy from file/database. - * - * @param mixed $filter - * @return void - */ - public function loadIncrementalFilteredPolicy($filter): void - { - $this->_loadFilteredPolicy($filter); - } - - /** - * Returns true if the loaded policy has been filtered. - * - * @return bool - */ - public function isFiltered(): bool - { - if (!$this->adapter instanceof FilteredAdapter) { - return false; - } - - $filteredAdapter = $this->adapter; - - return $filteredAdapter->isFiltered(); - } - - /** - * Saves the current policy (usually after changed with Casbin API) back to file/database. - * - * @throws CasbinException - */ - public function savePolicy(): void - { - if ($this->isFiltered()) { - throw new CasbinException('cannot save a filtered policy'); - } - - $this->adapter?->savePolicy($this->model); - - if ($this->autoNotifyWatcher) { - if ($this->watcher instanceof WatcherEx) { - $this->watcher->updateForSavePolicy($this->model); - } else { - $this->watcher?->update(); - } - } - } - - /** - * initRmMap initializes rmMap. - * - * @return void - */ - public function initRmMap(): void - { - if (isset($this->model['g'])) { - foreach ($this->model['g'] as $ptype => $value) { - if (isset($this->rmMap[$ptype])) { - $rm = $this->rmMap[$ptype]; - $rm->clear(); - continue; - } - - $tokensCount = count($value->tokens); - $paramsTokensCount = count($value->paramsTokens); - if ($tokensCount <= 2) { - if ($paramsTokensCount === 0) { - $value->rm = new DefaultRoleManager(10); - $this->rmMap[$ptype] = $value->rm; - } else { - $value->condRm = new DefaultConditionalRoleManager(10); - $this->condRmMap[$ptype] = $value->condRm; - } - } - if ($tokensCount > 2) { - if ($paramsTokensCount === 0) { - $value->rm = new DefaultDomainManager(10); - $this->rmMap[$ptype] = $value->rm; - } else { - $value->condRm = new DefaultConditionalDomainManager(10); - $this->condRmMap[$ptype] = $value->condRm; - } - $matchFunc = 'keyMatch(r_dom, p_dom)'; - if (str_contains($this->model['m']['m']->value, $matchFunc)) { - $this->addNamedDomainMatchingFunc('g', 'keyMatch', fn(string $key1, string $key2) => BuiltinOperations::keyMatch($key1, $key2)); - } - } - } - } - } - - /** - * Changes the enforcing state of Casbin, when Casbin is disabled, all access will be allowed by the Enforce() function. - * - * @param bool $enabled - */ - public function enableEnforce(bool $enabled = true): void - { - $this->enabled = $enabled; - } - - /** - * Changes whether Casbin will log messages to the Logger. - * - * @param bool $enabled - */ - public function enableLog(bool $enabled = true): void - { - $this->logger->enableLog($enabled); - } - - /** - * Controls whether to save a policy rule automatically notify the Watcher when it is added or removed. - * - * @param bool $enabled - */ - public function enableAutoNotifyWatcher(bool $enabled = true): void - { - $this->autoNotifyWatcher = $enabled; - } - - /** - * Controls whether to save a policy rule automatically to the adapter when it is added or removed. - * - * @param bool $autoSave - */ - public function enableAutoSave(bool $autoSave = true): void - { - $this->autoSave = $autoSave; - } - - /** - * Controls whether to rebuild the role inheritance relations when a role is added or deleted. - * - * @param bool $autoBuildRoleLinks - */ - public function enableAutoBuildRoleLinks(bool $autoBuildRoleLinks = true): void - { - $this->autoBuildRoleLinks = $autoBuildRoleLinks; - } - - /** - * Manually rebuild the role inheritance relations. - */ - public function buildRoleLinks(): void - { - foreach ($this->rmMap as $rm) { - $rm->clear(); - } - - $this->model->buildRoleLinks($this->rmMap); - } - - /** - * Use a custom matcher to decides whether a "subject" can access a "object" with the operation "action", - * input parameters are usually: (matcher, sub, obj, act), use model matcher by default when matcher is "". - * - * @param string $matcher - * @param array $explains - * @param mixed ...$rvals - * - * @return bool - * - * @throws CasbinException - */ - protected function enforcing(string $matcher, &$explains = [], ...$rvals): bool - { - if (!$this->enabled) { - return true; - } - - $functions = $this->fm->getFunctions(); - - if (isset($this->model['g'])) { - foreach ($this->model['g'] as $key => $ast) { - if (!is_null($ast->rm)) { - $functions[$key] = BuiltinOperations::generateGFunction($ast->rm); - } - if (!is_null($ast->condRm)) { - $functions[$key] = BuiltinOperations::generateConditionalGFunction($ast->condRm); - } - } - } - - if (!isset($this->model['m']['m'])) { - throw new CasbinException('model is undefined'); - } - - $rType = "r"; - $pType = "p"; - $eType = "e"; - $mType = "m"; - - switch (true) { - case $rvals[0] instanceof EnforceContext: - $enforceContext = $rvals[0]; - $rType = $enforceContext->rType; - $pType = $enforceContext->pType; - $eType = $enforceContext->eType; - $mType = $enforceContext->mType; - array_shift($rvals); - break; - default: - break; - } - - $expString = ''; - if ('' === $matcher) { - $expString = $this->model['m'][$mType]->value; - } else { - $expString = stripInlineComments(escapeDotsInAssertion($matcher)); - } - - $rTokens = array_values($this->model['r'][$rType]->tokens); - $pTokens = array_values($this->model['p'][$pType]->tokens); - - if (count($rTokens) != count($rvals)) { - throw new CasbinException(\sprintf('invalid request size: expected %d, got %d', count($rTokens), count($rvals))); - } - $rParameters = array_combine($rTokens, $rvals); - - if (false == $rParameters) { - throw new CasbinException('invalid request size'); - } - - $expressionLanguage = $this->getExpressionLanguage($functions); - $expression = ""; - - $hasEval = containsEval($expString); - - if (!$hasEval) { - $expression = $expressionLanguage->parse($expString, array_merge($rTokens, $pTokens)); - } - - $policyEffects = []; - $matcherResults = []; - - $effect = 0; - $explainIndex = 0; - - $policyLen = count($this->model['p'][$pType]->policy); - if (0 != $policyLen && str_contains($expString, $pType . '_')) { - foreach ($this->model['p'][$pType]->policy as $policyIndex => $pvals) { - $parameters = array_combine($pTokens, $pvals); - if (false == $parameters) { - throw new CasbinException('invalid policy size'); - } - - if ($hasEval) { - $ruleNames = extractEvalParameters($expString); - $replacements = []; - $pTokens_flipped = array_flip($pTokens); - foreach ($ruleNames as $ruleName) { - if (isset($pTokens_flipped[$ruleName])) { - $rule = escapeDotsInAssertion($pvals[$pTokens_flipped[$ruleName]]); - $replacements[$ruleName] = $rule; - } else { - throw new CasbinException('please make sure rule exists in policy when using eval() in matcher'); - } - } - - $expWithRule = replaceEvalWithMappings($expString, $replacements); - $expression = $expressionLanguage->parse($expWithRule, array_merge($rTokens, $pTokens)); - } - - $parameters = array_merge($rParameters, $parameters); - $result = $expressionLanguage->evaluate($expression, $parameters); - - // set to no-match at first - $matcherResults[$policyIndex] = 0; - if (is_bool($result)) { - if ($result) { - $matcherResults[$policyIndex] = 1; - } - } elseif (is_float($result)) { - if ($result != 0) { - $matcherResults[$policyIndex] = 1; - } - } else { - throw new CasbinException('matcher result should be bool, int or float'); - } - if (isset($parameters[$pType . '_eft'])) { - $eft = $parameters[$pType . '_eft']; - if ('allow' == $eft) { - $policyEffects[$policyIndex] = Effector::ALLOW; - } elseif ('deny' == $eft) { - $policyEffects[$policyIndex] = Effector::DENY; - } else { - $policyEffects[$policyIndex] = Effector::INDETERMINATE; - } - } else { - $policyEffects[$policyIndex] = Effector::ALLOW; - } - - list($effect, $explainIndex) = $this->eft->mergeEffects($this->model['e'][$eType]->value, $policyEffects, $matcherResults, $policyIndex, $policyLen); - if ($effect != Effector::INDETERMINATE) { - break; - } - } - } else { - if ($hasEval) { - throw new EvalFunctionException("please make sure rule exists in policy when using eval() in matcher"); - } - - $matcherResults[0] = 1; - - $parameters = $rParameters; - foreach ($this->model['p'][$pType]->tokens as $token) { - $parameters[$token] = ''; - } - - $result = $expressionLanguage->evaluate($expression, $parameters); - - if ($result) { - $policyEffects[0] = Effector::ALLOW; - } else { - $policyEffects[0] = Effector::INDETERMINATE; - } - - list($effect, $explainIndex) = $this->eft->mergeEffects($this->model['e'][$eType]->value, $policyEffects, $matcherResults, 0, 1); - } - - if ($explains !== null) { - if (($explainIndex != -1) && (count($this->model['p'][$pType]->policy) > $explainIndex)) { - $explains = $this->model['p'][$pType]->policy[$explainIndex]; - } - } - - $result = $effect == Effector::ALLOW; - - $this->logger->logEnforce($matcher, $rvals, $result, $explains); - - return $result; - } - - /** - * @param array $functions - * - * @return ExpressionLanguage - */ - protected function getExpressionLanguage(array $functions): ExpressionLanguage - { - $expressionLanguage = new ExpressionLanguage(); - foreach ($functions as $key => $func) { - $expressionLanguage->register($key, function (...$args) use ($key) { - return sprintf($key . '(%1$s)', implode(',', $args)); - }, function ($arguments, ...$args) use ($func) { - return $func(...$args); - }); - } - - return $expressionLanguage; - } - - /** - * @param string $expString - * - * @return string - */ - protected function getExpString(string $expString): string - { - return preg_replace_callback( - '/([\s\S]*in\s+)\(([\s\S]+)\)([\s\S]*)/', - function ($m) { - return $m[1] . '[' . $m[2] . ']' . $m[3]; - }, - $expString - ); - } - - /** - * Decides whether a "subject" can access a "object" with the operation "action", input parameters are usually: (sub, obj, act). - * - * @param mixed ...$rvals - * - * @return bool - * - * @throws CasbinException - */ - public function enforce(...$rvals): bool - { - $explains = []; - return $this->enforcing('', $explains, ...$rvals); - } - - /** - * Use a custom matcher to decides whether a "subject" can access a "object" with the operation "action", - * input parameters are usually: (matcher, sub, obj, act), use model matcher by default when matcher is "". - * - * @param string $matcher - * @param mixed ...$rvals - * - * @return bool - * - * @throws CasbinException - */ - public function enforceWithMatcher(string $matcher, ...$rvals): bool - { - $explains = []; - return $this->enforcing($matcher, $explains, ...$rvals); - } - - /** - * EnforceEx explain enforcement by informing matched rules - * - * @param mixed ...$rvals - * @return array - */ - public function enforceEx(...$rvals) - { - $explain = []; - $result = $this->enforcing("", $explain, ...$rvals); - return [$result, $explain]; - } - - /** - * BuildIncrementalRoleLinks provides incremental build the role inheritance relations. - * - * @param integer $op policy operations. - * @param string $ptype policy type. - * @param string[][] $rules the rules. - * @return void - */ - public function buildIncrementalRoleLinks(int $op, string $ptype, array $rules): void - { - $this->model->buildIncrementalRoleLinks($this->rmMap, $op, "g", $ptype, $rules); - } - - /** - * BuildIncrementalConditionalRoleLinks provides incremental build the conditional role inheritance relations. - * - * @param integer $op policy operations. - * @param string $ptype policy type. - * @param string[][] $rules the rules. - * @return void - */ - public function buildIncrementalConditionalRoleLinks(int $op, string $ptype, array $rules): void - { - $this->model->buildIncrementalConditionalRoleLinks($this->condRmMap, $op, "g", $ptype, $rules); - } - - /** - * BatchEnforce enforce in batches - * - * @param string[][] $requests - * @return bool[] - */ - public function batchEnforce(array $requests): array - { - return array_map(function (array $request) { - return $this->enforce(...$request); - }, $requests); - } - - /** - * BatchEnforceWithMatcher enforce with matcher in batches - * - * @param string $matcher - * @param string[][] $requests - * @return bool[] - */ - public function batchEnforceWithMatcher(string $matcher, array $requests): array - { - return array_map(function (array $request) use ($matcher) { - return $this->enforceWithMatcher($matcher, ...$request); - }, $requests); - } - - /** - * AddNamedMatchingFunc add MatchingFunc by ptype RoleManager - * - * @param string $ptype - * @param string $name - * @param Closure $fn - * @return boolean - */ - public function addNamedMatchingFunc(string $ptype, string $name, Closure $fn): bool - { - if (isset($this->rmMap[$ptype])) { - $rm = &$this->rmMap[$ptype]; - $rm->addMatchingFunc($name, $fn); - return true; - } - return false; - } - - /** - * AddNamedDomainMatchingFunc add MatchingFunc by ptype to RoleManager - * - * @param string $ptype - * @param string $name - * @param Closure $fn - * @return boolean - */ - public function addNamedDomainMatchingFunc(string $ptype, string $name, Closure $fn): bool - { - if (isset($this->rmMap[$ptype])) { - $rm = &$this->rmMap[$ptype]; - $rm->addDomainMatchingFunc($name, $fn); - return true; - } - return false; - } - - /** - * AddNamedLinkConditionFunc Add condition function fn for Link userName->roleName, - * when fn returns true, Link is valid, otherwise invalid. - * - * @param string $ptype - * @param string $user - * @param string $role - * @param Closure $fn - - * @return boolean - */ - public function addNamedLinkConditionFunc(string $ptype, string $user, string $role, Closure $fn): bool - { - if (isset($this->condRmMap[$ptype])) { - $rm = &$this->condRmMap[$ptype]; - $rm->addLinkConditionFunc($user, $role, $fn); - return true; - } - return false; - } - - /** - * AddNamedDomainLinkConditionFunc Add condition function fn for Link userName-> {roleName, domain}, - * when fn returns true, Link is valid, otherwise invalid. - * - * @param string $ptype - * @param string $user - * @param string $role - * @param string $domain - * @param Closure $fn - * - * @return boolean - */ - public function addNamedDomainLinkConditionFunc(string $ptype, string $user, string $role, string $domain, Closure $fn): bool - { - if (isset($this->condRmMap[$ptype])) { - $rm = &$this->condRmMap[$ptype]; - $rm->addDomainLinkConditionFunc($user, $role, $domain, $fn); - return true; - } - return false; - } - - /** - * SetNamedLinkConditionFuncParams Sets the parameters of the condition function fn for Link userName->roleName. - * - * @param string $ptype - * @param string $user - * @param string $role - * @param string ...$params - * - * @return boolean - */ - public function setNamedLinkConditionFuncParams(string $ptype, string $user, string $role, string ...$params): bool - { - if (isset($this->condRmMap[$ptype])) { - $rm = &$this->condRmMap[$ptype]; - $rm->setLinkConditionFuncParams($user, $role, ...$params); - return true; - } - return false; - } - - /** - * SetNamedDomainLinkConditionFuncParams Sets the parameters of the condition function fn - * for Link userName->{roleName, domain}. - * - * @param string $ptype - * @param string $user - * @param string $role - * @param string $domain - * @param string ...$params - * - * @return boolean - */ - public function setNamedDomainLinkConditionFuncParams(string $ptype, string $user, string $role, string $domain, string ...$params): bool - { - if (isset($this->condRmMap[$ptype])) { - $rm = &$this->condRmMap[$ptype]; - $rm->setDomainLinkConditionFuncParams($user, $role, $domain, ...$params); - return true; - } - return false; - } -} diff --git a/plugins/Authorization/Engine/Effector/DefaultEffector.php b/plugins/Authorization/Engine/Effector/DefaultEffector.php deleted file mode 100644 index 38dc18b..0000000 --- a/plugins/Authorization/Engine/Effector/DefaultEffector.php +++ /dev/null @@ -1,112 +0,0 @@ - $eft) { - if ($matches[$i] == 0) { - continue; - } - - if ($eft === Effector::ALLOW) { - $result = Effector::ALLOW; - // set hit rule to first matched allow rule - $explainIndex = $i; - break; - } - } - break; - case Constants::PRIORITY_EFFECT: - case Constants::SUBJECT_PRIORITY_EFFECT: - // reverse merge, short-circuit may be earlier - for ($i = count($effects) - 1; $i >= 0; $i--) { - if ($matches[$i] == 0) { - continue; - } - - if ($effects[$i] != Effector::INDETERMINATE) { - if ($effects[$i] === Effector::ALLOW) { - $result = Effector::ALLOW; - } else { - $result = Effector::DENY; - } - $explainIndex = $i; - break; - } - } - break; - default: - throw new CasbinException('unsupported effect'); - } - - return [$result, $explainIndex]; - } -} diff --git a/plugins/Authorization/Engine/Effector/Effector.php b/plugins/Authorization/Engine/Effector/Effector.php deleted file mode 100644 index b64a87c..0000000 --- a/plugins/Authorization/Engine/Effector/Effector.php +++ /dev/null @@ -1,30 +0,0 @@ -rType = "r" . $suffix; - $this->pType = "p" . $suffix; - $this->eType = "e" . $suffix; - $this->mType = "m" . $suffix; - } -} diff --git a/plugins/Authorization/Engine/Enforcer.php b/plugins/Authorization/Engine/Enforcer.php deleted file mode 100644 index 784893e..0000000 --- a/plugins/Authorization/Engine/Enforcer.php +++ /dev/null @@ -1,874 +0,0 @@ -model['g']['g']->rm->getRoles($name, ...$domain); - } - - /** - * Gets the users that has a role. - * - * @param string $name - * @param string ...$domain - * - * @return string[] - */ - public function getUsersForRole(string $name, string ...$domain): array - { - return $this->model['g']['g']->rm->getUsers($name, ...$domain); - } - - /** - * Determines whether a user has a role. - * - * @param string $name - * @param string $role - * @param string ...$domain - * - * @return bool - */ - public function hasRoleForUser(string $name, string $role, string ...$domain): bool - { - $roles = $this->getRolesForUser($name, ...$domain); - - return in_array($role, $roles, true); - } - - /** - * Adds a role for a user. - * returns false if the user already has the role (aka not affected). - * - * @param string $user - * @param string $role - * @param string ...$domain - * @return bool - */ - public function addRoleForUser(string $user, string $role, string ...$domain): bool - { - return $this->addGroupingPolicy(...array_merge([$user, $role], $domain)); - } - - /** - * @param string $user - * @param string[] $roles - * @param string ...$domain - * - * @return bool - */ - public function addRolesForUser(string $user, array $roles, string ...$domain): bool - { - return $this->addGroupingPolicies( - array_map(function ($role) use ($user, $domain) { - return array_merge([$user, $role], $domain); - }, $roles) - ); - } - - /** - * Deletes a role for a user. - * returns false if the user does not have the role (aka not affected). - * - * @param string $user - * @param string $role - * @param string ...$domain - * - * @return bool - */ - public function deleteRoleForUser(string $user, string $role, string ...$domain): bool - { - return $this->removeGroupingPolicy(...array_merge([$user, $role], $domain)); - } - - /** - * Deletes all roles for a user. - * Returns false if the user does not have any roles (aka not affected). - * - * @param string $user - * @param string ...$domain - * - * @return bool - * @throws CasbinException - */ - public function deleteRolesForUser(string $user, string ...$domain): bool - { - if (count($domain) > 1) { - throw new CasbinException('error: domain should be 1 parameter'); - } - - return $this->removeFilteredGroupingPolicy(0, ...array_merge([$user, ''], $domain)); - } - - /** - * Deletes a user. - * Returns false if the user does not exist (aka not affected). - * - * @param string $user - * - * @return bool - */ - public function deleteUser(string $user): bool - { - $res1 = $this->removeFilteredGroupingPolicy(0, $user); - - $subIndex = $this->model->getFieldIndex('p', Constants::SUBJECT_INDEX); - $res2 = $this->removeFilteredPolicy($subIndex, $user); - - return $res1 || $res2; - } - - /** - * Deletes a role. - * - * @param string $role - * @return bool - */ - public function deleteRole(string $role): bool - { - $res1 = $this->removeFilteredGroupingPolicy(1, $role); - - $subIndex = $this->model->getFieldIndex('p', Constants::SUBJECT_INDEX); - $res2 = $this->removeFilteredPolicy($subIndex, $role); - - return $res1 || $res2; - } - - /** - * Deletes a permission. - * Returns false if the permission does not exist (aka not affected). - * - * @param string ...$permission - * - * @return bool - */ - public function deletePermission(string ...$permission): bool - { - return $this->removeFilteredPolicy(1, ...$permission); - } - - /** - * Adds a permission for a user or role. - * Returns false if the user or role already has the permission (aka not affected). - * - * @param string $user - * @param string ...$permission - * - * @return bool - */ - public function addPermissionForUser(string $user, string ...$permission): bool - { - $params = array_merge([$user], $permission); - - return $this->addPolicy(...$params); - } - - /** - * AddPermissionsForUser adds multiple permissions for a user or role. - * Returns false if the user or role already has one of the permissions (aka not affected). - * - * @param string $user - * @param array ...$permissions - * @return bool - */ - public function addPermissionsForUser(string $user, array ...$permissions): bool - { - $rules = []; - foreach ($permissions as $permission) { - $rules[] = array_merge([$user], $permission); - } - return $this->addPolicies($rules); - } - - /** - * Deletes a permission for a user or role. - * Returns false if the user or role does not have the permission (aka not affected). - * - * @param string $user - * @param string ...$permission - * - * @return bool - */ - public function deletePermissionForUser(string $user, string ...$permission): bool - { - $params = array_merge([$user], $permission); - - return $this->removePolicy(...$params); - } - - /** - * Deletes permissions for a user or role. - * Returns false if the user or role does not have any permissions (aka not affected). - * - * @param string $user - * - * @return bool - */ - public function deletePermissionsForUser(string $user): bool - { - $subIndex = $this->model->getFieldIndex('p', Constants::SUBJECT_INDEX); - return $this->removeFilteredPolicy($subIndex, $user); - } - - /** - * Gets permissions for a user or role. - * - * @param string $user - * @param string ...$domain - * - * @return array - */ - public function getPermissionsForUser(string $user, string ...$domain): array - { - $permission = []; - foreach ($this->model['p'] as $ptype => $assertion) { - $args = []; - $subIndex = $this->model->getFieldIndex('p', Constants::SUBJECT_INDEX); - $args[$subIndex] = $user; - if (count($domain) > 0) { - $domIndex = $this->model->getFieldIndex($ptype, Constants::DOMAIN_INDEX); - $args[$domIndex] = $domain[0]; - } - $perm = $this->getFilteredPolicy(0, ...$args); - $permission = array_merge($permission, $perm); - } - return $permission; - } - - /** - * Determines whether a user has a permission. - * - * @param string $user - * @param string ...$permission - * - * @return bool - */ - public function hasPermissionForUser(string $user, string ...$permission): bool - { - $params = array_merge([$user], $permission); - - return $this->hasPolicy($params); - } - - /** - * Gets implicit roles that a user has. - * Compared to getRolesForUser(), this function retrieves indirect roles besides direct roles. - * For example: - * g, alice, role:admin - * g, role:admin, role:user. - * - * getRolesForUser("alice") can only get: ["role:admin"]. - * But getImplicitRolesForUser("alice") will get: ["role:admin", "role:user"]. - * - * @param string $name - * @param string ...$domain - * - * @return array - */ - public function getImplicitRolesForUser(string $name, string ...$domain): array - { - $res = []; - $roleSet = []; - $roleSet[$name] = true; - - $q = []; - $q[] = $name; - - for (; count($q) > 0;) { - $name = $q[0]; - $q = array_slice($q, 1); - - foreach ($this->rmMap as $rm) { - $roles = $rm->getRoles($name, ...$domain); - foreach ($roles as $r) { - if (!isset($roleSet[$r])) { - $res[] = $r; - $q[] = $r; - $roleSet[$r] = true; - } - } - } - } - - return $res; - } - - /** - * GetImplicitUsersForRole gets implicit users for a role. - * - * @param string $name - * @param string ...$domain - * @return array - */ - public function getImplicitUsersForRole(string $name, string ...$domain): array - { - $res = []; - $roleSet = []; - $roleSet[$name] = true; - - $q = []; - $q[] = $name; - - for (; count($q) > 0;) { - $name = $q[0]; - $q = array_slice($q, 1); - - foreach ($this->rmMap as $rm) { - $roles = $rm->getUsers($name, ...$domain); - foreach ($roles as $r) { - if (!isset($roleSet[$r])) { - $res[] = $r; - $q[] = $r; - $roleSet[$r] = true; - } - } - } - } - return $res; - } - - /** - * GetDomainsForUser gets all domains that a subject inherits. - * - * @param string $user - * - * @return string[] - */ - public function getDomainsForUser(string $user): array - { - $domains = []; - foreach ($this->rmMap as $rm) { - $res = $rm->getDomains($user); - $domains = array_merge($domains, $res); - } - - return $domains; - } - - /** - * GetImplicitResourcesForUser returns all policies that user obtaining in domain - * - * @param string $user - * @param string ...$domain - * @return array - */ - public function getImplicitResourcesForUser(string $user, string ...$domain): array - { - $permissions = $this->getImplicitPermissionsForUser($user, ...$domain); - - $res = []; - foreach ($permissions as $permission) { - if ($permission[0] == $user) { - $res[] = $permission; - continue; - } - $resLocal = [[$user]]; - $tokensLength = count($permission); - $t = [[]]; - foreach (array_slice($permission, 1) as $token) { - $tokens = $this->getImplicitUsersForRole($token, ...$domain); - $tokens[] = $token; - $t[] = $tokens; - } - - for ($i = 1; $i < $tokensLength; $i++) { - $n = []; - foreach ($t[$i] as $tokens) { - foreach ($resLocal as $policy) { - $temp = []; - $temp = array_merge($temp, $policy); - $temp[] = $tokens; - $n[] = $temp; - } - } - $resLocal = $n; - } - $res = array_merge($res, $resLocal); - } - return $res; - } - - /** - * Gets implicit permissions for a user or role. - * Compared to getPermissionsForUser(), this function retrieves permissions for inherited roles. - * For example: - * p, admin, data1, read - * p, alice, data2, read - * g, alice, admin. - * - * getPermissionsForUser("alice") can only get: [["alice", "data2", "read"]]. - * But getImplicitPermissionsForUser("alice") will get: [["admin", "data1", "read"], ["alice", "data2", "read"]]. - * - * @param string $user - * @param string ...$domain - * - * @return array - * @throws CasbinException - */ - public function getImplicitPermissionsForUser(string $user, string ...$domain): array - { - $roles = array_merge( - [$user], - $this->getImplicitRolesForUser($user, ...$domain) - ); - - $len = count($domain); - if ($len > 1) { - throw new CasbinException('error: domain should be 1 parameter'); - } - - $res = []; - foreach ($roles as $role) { - if (1 == $len) { - $permissions = $this->getPermissionsForUserInDomain($role, $domain[0]); - } else { - $permissions = $this->getPermissionsForUser($role); - } - - $res = array_merge($res, $permissions); - } - - return $res; - } - - /** - * Gets implicit users for a permission. - * For example: - * p, admin, data1, read - * p, bob, data1, read - * g, alice, admin - * getImplicitUsersForPermission("data1", "read") will get: ["alice", "bob"]. - * Note: only users will be returned, roles (2nd arg in "g") will be excluded. - * - * @param string ...$permission - * - * @return array - * @throws CasbinException - */ - public function getImplicitUsersForPermission(string ...$permission): array - { - $pSubjects = $this->getAllSubjects(); - $gInherit = $this->model->getValuesForFieldInPolicyAllTypes("g", 1); - $gSubjects = $this->model->getValuesForFieldInPolicyAllTypes("g", 0); - - $subjects = array_merge($pSubjects, $gSubjects); - arrayRemoveDuplicates($subjects); - - $subjects = array_diff($subjects, $gInherit); - - $res = []; - foreach ($subjects as $user) { - $req = $permission; - array_unshift($req, $user); - $allowed = $this->enforce(...$req); - - if ($allowed) { - $res[] = $user; - } - } - - return $res; - } - - /** - * Convert permissions to string as a hash to deduplicate. - * - * @param array $permissions - * - * @return array - */ - private function removeDumplicatePermissions(array $permissions): array - { - $permissionsSet = []; - $res = []; - - foreach ($permissions as $permission) { - $permissionStr = arrayToCommaSeparatedStr($permission); - - if (isset($permissionsSet[$permissionStr])) { - continue; - } - - $permissionsSet[$permissionStr] = true; - $res[] = $permission; - } - return $res; - } - - /** - * GetAllowedObjectConditions returns a string array of object conditions that the user can access. - * For example: conditions, err := e.GetAllowedObjectConditions("alice", "read", "r.obj.") - * Note: - * - * 0. prefix: You can customize the prefix of the object conditions, and "r.obj." is commonly used as a prefix. - * After removing the prefix, the remaining part is the condition of the object. - * If there is an obj policy that does not meet the prefix requirement, an ObjConditionException will be thrown. - * - * 1. If the 'objectConditions' array is empty, an EmptyConditionException will be thrown. - * This error is thrown because some data adapters' ORM return full table data by default - * when they receive an empty condition, which tends to behave contrary to expectations.(e.g. DBALAdapter) - * If you are using an adapter that does not behave like this, you can choose to ignore this error. - * - * @param string $user - * @param string $action - * @param string $prefix - * - * @return array - * @throws ObjConditionException - * @throws EmptyConditionException - */ - public function getAllowedObjectConditions(string $user, string $action, string $prefix): array - { - $permission = $this->getImplicitPermissionsForUser($user); - - $objectConditions = []; - foreach ($permission as $policy) { - if ($policy[2] == $action) { - if (!str_starts_with($policy[1], $prefix)) { - throw new ObjConditionException('need to meet the prefix required by the object condition'); - } - - $objectConditions[] = substr($policy[1], strlen($prefix)); - } - } - - if (empty($objectConditions)) { - throw new EmptyConditionException('GetAllowedObjectConditions have an empty condition'); - } - - return $objectConditions; - } - - /** - * GetImplicitUsersForResource return implicit user based on resource. - * For example: - * p, alice, data1, read - * p, bob, data2, write - * p, data2_admin, data2, read - * p, data2_admin, data2, write - * g, alice, data2_admin - * GetImplicitUsersForResource("data2") will return [[bob data2 write] [alice data2 read] [alice data2 write]] - * GetImplicitUsersForResource("data1") will return [[alice data1 read]] - * Note: only users will be returned, roles (2nd arg in "g") will be excluded. - * - * @param string $resource - * - * @return array - */ - public function getImplicitUsersForResource(string $resource): array - { - $permissions = []; - $subIndex = $this->model->getFieldIndex('p', Constants::SUBJECT_INDEX); - $objIndex = $this->model->getFieldIndex('p', Constants::OBJECT_INDEX); - $rm = $this->getRoleManager(); - - $roles = $this->getAllRoles(); - $isRole = array_flip($roles); - - foreach ($this->model['p']['p']->policy as $rule) { - $obj = $rule[$objIndex]; - if ($obj != $resource) { - continue; - } - - $sub = $rule[$subIndex]; - - if (!isset($isRole[$sub])) { - $permissions[] = $rule; - } else { - $users = $rm->getUsers($sub); - - foreach ($users as $user) { - $implicitRule = array_merge([], $rule); - $implicitRule[$subIndex] = $user; - $permissions[] = $implicitRule; - } - } - } - - $res = $this->removeDumplicatePermissions($permissions); - return $res; - } - - /** - * GetImplicitUsersForResourceByDomain return implicit user based on resource and domain. - * Compared to GetImplicitUsersForResource, domain is supported. - * - * @param string $resource - * @param string $domain - * - * @return array - */ - public function getImplicitUsersForResourceByDomain(string $resource, string $domain): array - { - $permissions = []; - $subIndex = $this->model->getFieldIndex('p', Constants::SUBJECT_INDEX); - $objIndex = $this->model->getFieldIndex('p', Constants::OBJECT_INDEX); - $domIndex = $this->model->getFieldIndex('p', Constants::DOMAIN_INDEX); - $rm = $this->getRoleManager(); - - $roles = $this->getAllRolesByDomain($domain); - $isRole = array_flip($roles); - - foreach ($this->model['p']['p']->policy as $rule) { - $obj = $rule[$objIndex]; - if ($obj != $resource) { - continue; - } - - $sub = $rule[$subIndex]; - - if (!isset($isRole[$sub])) { - $permissions[] = $rule; - } else { - if ($rule[$domIndex] != $domain) { - continue; - } - - $users = $rm->getUsers($sub, $domain); - foreach ($users as $user) { - $implicitRule = array_merge([], $rule); - $implicitRule[$subIndex] = $user; - $permissions[] = $implicitRule; - } - } - } - - $res = $this->removeDumplicatePermissions($permissions); - return $res; - } - - /** - * GetAllUsersByDomain would get all users associated with the domain. - * - * @param string $domain - * @return string[] - */ - public function getAllUsersByDomain(string $domain): array - { - $m = []; - $g = $this->model['g']['g']; - $p = $this->model['p']['p']; - $users = []; - $index = $this->model->getFieldIndex('p', Constants::DOMAIN_INDEX); - - $getUser = function (int $index, array $policies, string $domain, array $m): array { - if (count($policies) == 0 || count($policies[0]) <= $index) { - return []; - } - $res = []; - foreach ($policies as $policy) { - $ok = isset($m[$policy[0]]); - if ($policy[$index] == $domain && !$ok) { - $res[] = $policy[0]; - $m[$policy[0]] = []; - } - } - return $res; - }; - - $users = array_merge($users, $getUser(2, $g->policy, $domain, $m)); - $users = array_merge($users, $getUser($index, $p->policy, $domain, $m)); - return $users; - } - - /** - * Gets the users that has a role inside a domain. Add by Gordon. - * - * @param string $name - * @param string $domain - * - * @return array - */ - public function getUsersForRoleInDomain(string $name, string $domain): array - { - return $this->model['g']['g']->rm->getUsers($name, $domain); - } - - /** - * Gets the roles that a user has inside a domain. - * - * @param string $name - * @param string $domain - * - * @return array - */ - public function getRolesForUserInDomain(string $name, string $domain): array - { - return $this->model['g']['g']->rm->getRoles($name, $domain); - } - - /** - * Gets permissions for a user or role inside a domain. - * - * @param string $name - * @param string $domain - * - * @return array - */ - public function getPermissionsForUserInDomain(string $name, string $domain): array - { - return $this->getFilteredPolicy(0, $name, $domain); - } - - /** - * Adds a role for a user inside a domain. - * returns false if the user already has the role (aka not affected). - * - * @param string $user - * @param string $role - * @param string $domain - * - * @return bool - */ - public function addRoleForUserInDomain(string $user, string $role, string $domain): bool - { - return $this->addGroupingPolicy($user, $role, $domain); - } - - /** - * Deletes a role for a user inside a domain. - * Returns false if the user does not have the role (aka not affected). - * - * @param string $user - * @param string $role - * @param string $domain - * - * @return bool - */ - public function deleteRoleForUserInDomain(string $user, string $role, string $domain): bool - { - return $this->removeGroupingPolicy($user, $role, $domain); - } - - /** - * DeleteRolesForUserInDomain deletes all roles for a user inside a domain. - * Returns false if the user does not have any roles (aka not affected). - * - * @param string $user - * @param string $domain - * - * @return bool - */ - public function deleteRolesForUserInDomain(string $user, string $domain): bool - { - $roles = $this->model['g']['g']->rm->getRoles($user, $domain); - - $rules = []; - foreach ($roles as $role) { - $rules[] = [$user, $role, $domain]; - } - - return $this->removeGroupingPolicies($rules); - } - - /** - * DeleteAllUsersByDomain would delete all users associated with the domain. - * - * @param string $domain - * @return bool - */ - public function deleteAllUsersByDomain(string $domain): bool - { - $g = $this->model['g']['g']; - $p = $this->model['p']['p']; - $index = $this->model->getFieldIndex('p', Constants::DOMAIN_INDEX); - - $getUser = function (int $index, array $policies, string $domain): array { - if (count($policies) == 0 || count($policies[0]) <= $index) { - return []; - } - $res = []; - foreach ($policies as $policy) { - if ($policy[$index] == $domain) { - $res[] = $policy; - } - } - return $res; - }; - - $users = $getUser(2, $g->policy, $domain); - $this->removeGroupingPolicies($users); - $users = $getUser($index, $p->policy, $domain); - $this->removePolicies($users); - return true; - } - - /** - * DeleteDomains would delete all associated users and roles. - * It would delete all domains if parameter is not provided. - * - * @param string ...$domains - * @return bool - */ - public function deleteDomains(string ...$domains): bool - { - if (count($domains) == 0) { - $this->clearPolicy(); - return true; - } - foreach ($domains as $domain) { - $this->deleteAllUsersByDomain($domain); - } - return true; - } - - /** - * GetAllDomains would get all domains. - * - * @return array - */ - public function getAllDomains(): array - { - return $this->getRoleManager()->getAllDomains(); - } - - /** - * GetAllRolesByDomain would get all roles associated with the domain. - * Note: Not applicable to Domains with inheritance relationship (implicit roles) - * - * @param string $domain - * - * @return array - */ - public function getAllRolesByDomain(string $domain): array - { - $g = $this->model['g']['g']; - $policies = $g->policy; - $roles = []; - $existMap = []; - - foreach ($policies as $policy) { - if ($policy[count($policy) - 1] == $domain) { - $role = $policy[count($policy) - 2]; - if (!isset($existMap[$role])) { - $roles[] = $role; - $existMap[$role] = true; - } - } - } - - return $roles; - } -} diff --git a/plugins/Authorization/Engine/Exceptions/BatchOperationException.php b/plugins/Authorization/Engine/Exceptions/BatchOperationException.php deleted file mode 100644 index f28f223..0000000 --- a/plugins/Authorization/Engine/Exceptions/BatchOperationException.php +++ /dev/null @@ -1,14 +0,0 @@ - - */ -interface ConditionalRoleManager extends RoleManager -{ - /** - * Adds a conditional function for linking a user to a role. - * The link will only be valid if the condition function `fn` returns true. - * This allows for conditional role assignments based on specific logic (e.g., user age, group, etc.). - * - * Example usage: - * $roleManager->addLinkConditionFunc("john", "admin", function($userName, $roleName) { - * return $userName === "john" && $roleName === "admin"; // Only link "john" to "admin". - * }); - * - * @param string $userName The user to be linked to a role (e.g., "john"). - * @param string $roleName The role to be assigned to the user (e.g., "admin"). - * @param Closure $linkConditionFunc The condition function that must return true for the link to be valid. - * - * @return void - */ - public function addLinkConditionFunc(string $userName, string $roleName, Closure $linkConditionFunc): void; - - /** - * Sets the parameters for the condition function used in the link between user and role. - * This is used to provide additional context (parameters) to the condition function. - * - * Example usage: - * $roleManager->setLinkConditionFuncParams("john", "admin", "extra_param"); - * // Sets "extra_param" as an additional parameter for the link condition function. - * - * @param string $userName The user to whom the role is being linked (e.g., "john"). - * @param string $roleName The role being linked to the user (e.g., "admin"). - * @param string ...$params Additional parameters for the condition function. - * - * @return void - */ - public function setLinkConditionFuncParams(string $userName, string $roleName, string ...$params): void; - - /** - * Adds a conditional function for linking a user to a role within a specific domain. - * The link will only be valid if the condition function `fn` returns true. - * This supports more granular role assignments by domain, ensuring roles are applied conditionally in different contexts. - * - * Example usage: - * $roleManager->addDomainLinkConditionFunc("john", "admin", "sales", function($userName, $roleName, $domain) { - * return $userName === "john" && $roleName === "admin" && $domain === "sales"; // Only link in the "sales" domain. - * }); - * - * @param string $userName The user to be linked to a role (e.g., "john"). - * @param string $roleName The role to be assigned to the user (e.g., "admin"). - * @param string $domain The domain within which the role assignment will occur (e.g., "sales"). - * @param Closure $linkConditionFunc The condition function that must return true for the link to be valid. - * - * @return void - */ - public function addDomainLinkConditionFunc(string $userName, string $roleName, string $domain, Closure $linkConditionFunc): void; - - /** - * Sets the parameters for the condition function used in the link between user and role within a specific domain. - * This is useful for passing additional context that may affect the role assignment logic within the domain. - * - * Example usage: - * $roleManager->setDomainLinkConditionFuncParams("john", "admin", "sales", "extra_param"); - * // Sets "extra_param" as an additional parameter for the domain-specific link condition function. - * - * @param string $userName The user to whom the role is being linked (e.g., "john"). - * @param string $roleName The role being linked to the user (e.g., "admin"). - * @param string $domain The domain in which the role assignment is valid (e.g., "sales"). - * @param string ...$params Additional parameters for the condition function. - * - * @return void - */ - public function setDomainLinkConditionFuncParams(string $userName, string $roleName, string $domain, string ...$params): void; -} diff --git a/plugins/Authorization/Engine/Interfaces/Config.php b/plugins/Authorization/Engine/Interfaces/Config.php deleted file mode 100644 index f0bf7f9..0000000 --- a/plugins/Authorization/Engine/Interfaces/Config.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ -interface RoleManager -{ - const DEFAULT_DOMAIN = ''; // Default domain to be used when no domain is provided. - - /** - * Clears all stored data and resets the role manager to its initial state. - * This is typically used when you need to reset the role manager, e.g., for testing or reconfiguration. - * - * Example usage: - * $roleManager->clear(); // Clears all data and resets the state. - * - * @return void - */ - public function clear(): void; - - /** - * Adds an inheritance link between two roles, where `name1` inherits `name2`. - * Optionally, you can specify one or more domains that will act as prefixes for the roles. - * - * This allows you to create role hierarchies (e.g., "admin" inherits "user"). - * - * Example usage: - * $roleManager->addLink("admin", "user"); // "admin" inherits "user". - * $roleManager->addLink("admin", "user", "sales"); // "admin" inherits "user" in the "sales" domain. - * - * @param string $name1 The role that will inherit (e.g., "admin"). - * @param string $name2 The role being inherited (e.g., "user"). - * @param string ...$domain Optional domains that act as prefixes for roles (e.g., "sales"). - * - * @return void - */ - public function addLink(string $name1, string $name2, string ...$domain): void; - - /** - * Deletes the inheritance link between two roles, where `name1` no longer inherits `name2`. - * Optionally, you can specify one or more domains to target specific role inheritance links. - * - * Example usage: - * $roleManager->deleteLink("admin", "user"); // "admin" no longer inherits "user". - * $roleManager->deleteLink("admin", "user", "sales"); // "admin" no longer inherits "user" in the "sales" domain. - * - * @param string $name1 The role that will no longer inherit (e.g., "admin"). - * @param string $name2 The role that is no longer inherited (e.g., "user"). - * @param string ...$domain Optional domains that act as prefixes for roles (e.g., "sales"). - * - * @return void - */ - public function deleteLink(string $name1, string $name2, string ...$domain): void; - - /** - * Determines whether role `name1` inherits role `name2`. - * This operation supports domains to check inheritance in a domain-specific context. - * - * Example usage: - * $isInherited = $roleManager->hasLink("admin", "user"); // Returns true if "admin" inherits "user". - * $isInherited = $roleManager->hasLink("admin", "user", "sales"); // Returns true if "admin" inherits "user" in the "sales" domain. - * - * @param string $name1 The role to check for inheritance (e.g., "admin"). - * @param string $name2 The role being checked for inheritance (e.g., "user"). - * @param string ...$domain Optional domains to check against. - * - * @return bool Returns true if `name1` inherits `name2`, otherwise false. - */ - public function hasLink(string $name1, string $name2, string ...$domain): bool; - - /** - * Gets all the roles that a subject (e.g., user or group) `name` inherits. - * Optionally, you can specify domains to get domain-specific inherited roles. - * - * Example usage: - * $roles = $roleManager->getRoles("admin"); // Returns all roles inherited by "admin". - * $roles = $roleManager->getRoles("admin", "sales"); // Returns roles inherited by "admin" in the "sales" domain. - * - * @param string $name The subject whose inherited roles are being fetched. - * @param string ...$domain Optional domains to consider when fetching roles. - * - * @return string[] An array of roles inherited by the subject. - */ - public function getRoles(string $name, string ...$domain): array; - - /** - * Gets all the users that inherit the subject `name`. - * This is useful for finding which users have a certain role or permission. - * - * Example usage: - * $users = $roleManager->getUsers("admin"); // Returns all users who inherit "admin". - * $users = $roleManager->getUsers("admin", "sales"); // Returns users who inherit "admin" in the "sales" domain. - * - * @param string $name The subject whose inheritors are being fetched. - * @param string ...$domain Optional domains to consider when fetching users. - * - * @return string[] An array of users who inherit the subject. - */ - public function getUsers(string $name, string ...$domain): array; - - /** - * Prints all roles to the log for auditing or debugging purposes. - * This can be used to review the current role setup, useful for debugging or system auditing. - * - * Example usage: - * $roleManager->printRoles(); // Logs all roles for review. - * - * @return void - */ - public function printRoles(): void; - - /** - * Gets the domains that a subject `name` inherits. - * This is useful for understanding the scope or context in which a subject's roles apply. - * - * Example usage: - * $domains = $roleManager->getDomains("admin"); // Returns all domains where "admin" has roles. - * - * @param string $name The subject whose inherited domains are being fetched. - * - * @return string[] An array of domains inherited by the subject. - */ - public function getDomains(string $name): array; - - /** - * Sets the current logger. - * - * @param Logger $logger - * - * @return void - */ - public function setLogger(Logger $logger): void; - - /** - * Gets all available domains in the system. - * This method returns a list of all domains that are in use, which could represent different contexts or areas. - * - * Example usage: - * $domains = $roleManager->getAllDomains(); // Returns all available domains. - * - * @return string[] An array of all domains. - */ - public function getAllDomains(): array; - - /** - * Adds a custom matching function for a role. - * This function allows you to perform more complex or customized matches for roles based on custom logic. - * - * Example usage: - * $roleManager->addMatchingFunc("admin", function($role) { - * return strpos($role, "admin") === 0; // Custom logic to match roles starting with "admin". - * }); - * - * @param string $name The role for which the matching function is being added. - * @param Closure $fn A closure that defines the matching function. - * - * @return void - */ - public function addMatchingFunc(string $name, Closure $fn): void; - - /** - * Adds a custom domain matching function. - * This function allows you to perform more customized or domain-specific matches for roles. - * - * Example usage: - * $roleManager->addDomainMatchingFunc("sales", function($domain) { - * return $domain === "sales"; // Custom logic to match the "sales" domain. - * }); - * - * @param string $name The domain for which the matching function is being added. - * @param Closure $fn A closure that defines the domain matching function. - * - * @return void - */ - public function addDomainMatchingFunc(string $name, Closure $fn): void; -} diff --git a/plugins/Authorization/Engine/Interfaces/Supports/AccessPermission.php b/plugins/Authorization/Engine/Interfaces/Supports/AccessPermission.php deleted file mode 100644 index 9e7d29b..0000000 --- a/plugins/Authorization/Engine/Interfaces/Supports/AccessPermission.php +++ /dev/null @@ -1,171 +0,0 @@ -adapter) && $this->autoSave; - } - - /** - * @return bool - */ - protected function shouldNotify(): bool - { - return !is_null($this->watcher) && $this->autoNotifyWatcher; - } - - /** - * Adds a rule to the current policy without notify. - * - * @param string $sec - * @param string $ptype - * @param array $rule - * - * @return bool - */ - protected function addPolicyWithoutNotifyInternal(string $sec, string $ptype, array $rule): bool - { - if ($this->model->hasPolicy($sec, $ptype, $rule)) { - return false; - } - - if ($this->shouldPersist()) { - try { - $this->adapter->addPolicy($sec, $ptype, $rule); - } catch (NotImplementedException $e) { - } - } - - $this->model->addPolicy($sec, $ptype, $rule); - - if ($sec == "g") { - $this->buildIncrementalRoleLinks(Policy::POLICY_ADD, $ptype, [$rule]); - } - - return true; - } - - /** - * Adds rules to the current policy without notify. - * If autoRemoveRepeat == true, existing rules are automatically filtered - * Otherwise, false is returned directly. - * - * @param string $sec - * @param string $ptype - * @param array $rules - * @param bool $autoRemoveRepeat - * - * @return bool - */ - protected function addPoliciesWithoutNotifyInternal(string $sec, string $ptype, array $rules, bool $autoRemoveRepeat): bool - { - if (!$autoRemoveRepeat) { - if ($this->model->hasPolicies($sec, $ptype, $rules)) { - return false; - } - } - - if ($this->shouldPersist() && $this->adapter instanceof BatchAdapter) { - try { - $this->adapter->addPolicies($sec, $ptype, $rules); - } catch (NotImplementedException $e) { - } - } - - $this->model->addPolicies($sec, $ptype, $rules); - - if ($sec == "g") { - $this->buildIncrementalRoleLinks(Policy::POLICY_ADD, $ptype, $rules); - $this->buildIncrementalConditionalRoleLinks(Policy::POLICY_ADD, $ptype, $rules); - } - - return true; - } - - /** - * Updates a rule from the current policy without notify. - * - * @param string $sec - * @param string $ptype - * @param array $oldRule - * @param array $newRule - * - * @return bool - */ - protected function updatePolicyWithoutNotifyInternal(string $sec, string $ptype, array $oldRule, array $newRule): bool - { - if ($this->shouldPersist() && $this->adapter instanceof UpdatableAdapter) { - try { - $this->adapter->updatePolicy($sec, $ptype, $oldRule, $newRule); - } catch (NotImplementedException $e) { - } - } - - $ruleUpdated = $this->model->updatePolicy($sec, $ptype, $oldRule, $newRule); - if (!$ruleUpdated) { - return false; - } - - if ($sec == "g") { - // remove the old rule - $this->buildIncrementalRoleLinks(Policy::POLICY_REMOVE, $ptype, [$oldRule]); - - // add the new rule - $this->buildIncrementalRoleLinks(Policy::POLICY_ADD, $ptype, [$newRule]); - } - - return true; - } - - /** - * Updates rules from the current policy without notify. - * - * @param string $sec - * @param string $ptype - * @param array $oldRules - * @param array $newRules - * - * @return bool - */ - protected function updatePoliciesWithoutNotifyInternal(string $sec, string $ptype, array $oldRules, array $newRules): bool - { - if ($this->shouldPersist() && $this->adapter instanceof UpdatableAdapter) { - try { - $this->adapter->updatePolicies($sec, $ptype, $oldRules, $newRules); - } catch (NotImplementedException $e) { - } - } - - $ruleUpdated = $this->model->updatePolicies($sec, $ptype, $oldRules, $newRules); - if (!$ruleUpdated) { - return false; - } - - if ($sec == "g") { - // remove the old rule - $this->buildIncrementalRoleLinks(Policy::POLICY_REMOVE, $ptype, $oldRules); - - // add the new rule - $this->buildIncrementalRoleLinks(Policy::POLICY_ADD, $ptype, $newRules); - } - - return true; - } - - /** - * Removes a rule from the current policy without notify. - * - * @param string $sec - * @param string $ptype - * @param array $rule - * - * @return bool - */ - protected function removePolicyWithoutNotifyInternal(string $sec, string $ptype, array $rule): bool - { - if ($this->shouldPersist()) { - try { - $this->adapter->removePolicy($sec, $ptype, $rule); - } catch (NotImplementedException $e) { - } - } - - $ruleRemoved = $this->model->removePolicy($sec, $ptype, $rule); - if (!$ruleRemoved) { - return false; - } - - if ($sec == "g") { - $this->buildIncrementalRoleLinks(Policy::POLICY_REMOVE, $ptype, [$rule]); - } - - return true; - } - - /** - * Removes rules from the current policy without notify. - * - * @param string $sec - * @param string $ptype - * @param array $rules - * - * @return bool - */ - protected function removePoliciesWithoutNotifyInternal(string $sec, string $ptype, array $rules): bool - { - if (!$this->model->hasPolicies($sec, $ptype, $rules)) { - return false; - } - - if ($this->shouldPersist() && $this->adapter instanceof BatchAdapter) { - try { - $this->adapter->removePolicies($sec, $ptype, $rules); - } catch (NotImplementedException $e) { - } - } - - $ruleRemoved = $this->model->removePolicies($sec, $ptype, $rules); - if (!$ruleRemoved) { - return false; - } - - if ($sec == "g") { - $this->buildIncrementalRoleLinks(Policy::POLICY_REMOVE, $ptype, $rules); - } - - return true; - } - - /** - * Removes rules based on field filters from the current policy without notify. - * - * @param string $sec - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return bool - */ - protected function removeFilteredPolicyWithoutNotifyInternal(string $sec, string $ptype, int $fieldIndex, string ...$fieldValues): bool - { - if ($this->shouldPersist()) { - try { - $this->adapter->removeFilteredPolicy($sec, $ptype, $fieldIndex, ...$fieldValues); - } catch (NotImplementedException $e) { - } - } - - $ruleRemoved = $this->model->removeFilteredPolicy($sec, $ptype, $fieldIndex, ...$fieldValues); - if (!$ruleRemoved) { - return false; - } - - if ($sec == "g") { - $this->buildIncrementalRoleLinks(Policy::POLICY_REMOVE, $ptype, $ruleRemoved); - } - - return true; - } - - /** - * Updates rules based on field filters from the current policy without notify. - * - * @param string $sec - * @param string $ptype - * @param array $newRules - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return array - */ - protected function updateFilteredPoliciesWithoutNotifyInternal(string $sec, string $ptype, array $newRules, int $fieldIndex, string ...$fieldValues): array - { - $oldRules = []; - if ($this->shouldPersist()) { - try { - if ($this->adapter instanceof UpdatableAdapter) { - $oldRules = $this->adapter->updateFilteredPolicies($sec, $ptype, $newRules, $fieldIndex, ...$fieldValues); - } - } catch (NotImplementedException $e) { - } - } - - $ruleChanged = $this->model->removePolicies($sec, $ptype, $oldRules); - $this->model->addPolicies($sec, $ptype, $newRules); - - $ruleChanged = $ruleChanged && count($newRules) !== 0; - if (!$ruleChanged) { - return []; - } - - if ($sec == "g") { - // remove the old rules - $this->buildIncrementalRoleLinks(Policy::POLICY_REMOVE, $ptype, $oldRules); - // add the new rules - $this->buildIncrementalRoleLinks(Policy::POLICY_ADD, $ptype, $newRules); - } - - return $oldRules; - } - - /** - * Adds a rule to the current policy. - * - * @param string $sec - * @param string $ptype - * @param array $rule - * - * @return bool - */ - protected function addPolicyInternal(string $sec, string $ptype, array $rule): bool - { - if (!$this->addPolicyWithoutNotifyInternal($sec, $ptype, $rule)) { - return false; - } - - if ($this->shouldNotify()) { - if ($this->watcher instanceof WatcherEx) { - $this->watcher->updateForAddPolicy($sec, $ptype, ...$rule); - } else { - $this->watcher->update(); - } - } - - return true; - } - - /** - * Adds rules to the current policy. - * - * @param string $sec - * @param string $ptype - * @param array $rules - * - * @return bool - * @throws Exceptions\CasbinException - */ - protected function addPoliciesInternal(string $sec, string $ptype, array $rules, bool $autoRemoveRepeat): bool - { - if (!$this->addPoliciesWithoutNotifyInternal($sec, $ptype, $rules, $autoRemoveRepeat)) { - return false; - } - - if ($this->shouldNotify()) { - $this->watcher->update(); - } - - return true; - } - - /** - * Updates a rule from the current policy. - * - * @param string $sec - * @param string $ptype - * @param string[] $oldRule - * @param string[] $newRule - * - * @return bool - */ - protected function updatePolicyInternal(string $sec, string $ptype, array $oldRule, array $newRule): bool - { - if (!$this->updatePolicyWithoutNotifyInternal($sec, $ptype, $oldRule, $newRule)) { - return false; - } - - if ($this->shouldNotify()) { - try { - if ($this->watcher instanceof WatcherUpdatable) { - $this->watcher->updateForUpdatePolicy($oldRule, $newRule); - } else { - $this->watcher->update(); - } - } catch (\Exception $e) { - $this->logger->logError($e); - - return false; - } - } - - return true; - } - - /** - * Updates rules from the current policy. - * - * @param string $sec - * @param string $ptype - * @param string[][] $oldRules - * @param string[][] $newRules - * - * @return bool - */ - protected function updatePoliciesInternal(string $sec, string $ptype, array $oldRules, array $newRules): bool - { - if (!$this->updatePoliciesWithoutNotifyInternal($sec, $ptype, $oldRules, $newRules)) { - return false; - } - - if ($this->shouldNotify()) { - try { - if ($this->watcher instanceof WatcherUpdatable) { - $this->watcher->updateForUpdatePolicies($oldRules, $newRules); - } else { - $this->watcher->update(); - } - } catch (\Exception $e) { - $this->logger->logError($e); - return false; - } - } - - return true; - } - - /** - * Removes a rule from the current policy. - * - * @param string $sec - * @param string $ptype - * @param array $rule - * - * @return bool - */ - protected function removePolicyInternal(string $sec, string $ptype, array $rule): bool - { - if (!$this->removePolicyWithoutNotifyInternal($sec, $ptype, $rule)) { - return false; - } - - if ($this->shouldNotify()) { - if ($this->watcher instanceof WatcherEx) { - $this->watcher->updateForRemovePolicy($sec, $ptype, ...$rule); - } else { - $this->watcher->update(); - } - } - - return true; - } - - /** - * Removes a rules from the current policy. - * - * @param string $sec - * @param string $ptype - * @param array $rules - * - * @return bool - */ - protected function removePoliciesInternal(string $sec, string $ptype, array $rules): bool - { - if (!$this->removePoliciesWithoutNotifyInternal($sec, $ptype, $rules)) { - return false; - } - - if ($this->shouldNotify()) { - // error intentionally ignored - $this->watcher->update(); - } - - return true; - } - - /** - * Removes rules based on field filters from the current policy. - * - * @param string $sec - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return bool - */ - protected function removeFilteredPolicyInternal(string $sec, string $ptype, int $fieldIndex, string ...$fieldValues): bool - { - if (!$this->removeFilteredPolicyWithoutNotifyInternal($sec, $ptype, $fieldIndex, ...$fieldValues)) { - return false; - } - - if ($this->shouldNotify()) { - // error intentionally ignored - if ($this->watcher instanceof WatcherEx) { - $this->watcher->updateForRemoveFilteredPolicy($sec, $ptype, $fieldIndex, ...$fieldValues); - } else { - $this->watcher->update(); - } - } - - return true; - } - - /** - * Removes rules based on field filters from the current policy. - * - * @param string $sec - * @param string $ptype - * @param array $newRules - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return bool - */ - protected function updateFilteredPoliciesInternal(string $sec, string $ptype, array $newRules, int $fieldIndex, string ...$fieldValues): bool - { - $oldRules = $this->updateFilteredPoliciesWithoutNotifyInternal($sec, $ptype, $newRules, $fieldIndex, ...$fieldValues); - if (count($oldRules) === 0) { - return false; - } - - if ($this->shouldNotify()) { - // error intentionally ignored - if ($this->watcher instanceof WatcherUpdatable) { - $this->watcher->updateForUpdatePolicies($oldRules, $newRules); - } else { - $this->watcher->update(); - } - return true; - } - - return true; - } -} diff --git a/plugins/Authorization/Engine/Log/Log.php b/plugins/Authorization/Engine/Log/Log.php deleted file mode 100644 index aed3eab..0000000 --- a/plugins/Authorization/Engine/Log/Log.php +++ /dev/null @@ -1,109 +0,0 @@ -logModel($model); - } - - /** - * Log enforcer information. - * - * @param string $matcher - * @param array $request - * @param bool $result - * @param array $explains - * - * @return void - */ - public static function logEnforce(string $matcher, array $request, bool $result, array $explains): void - { - self::$logger->logEnforce($matcher, $request, $result, $explains); - } - - /** - * Log role information. - * - * @param array $roles - * - * @return void - */ - public static function logRole(array $roles): void - { - self::$logger->logRole($roles); - } - - /** - * Log policy information. - * - * @param array $policy - * - * @return void - */ - public static function logPolicy(array $policy): void - { - self::$logger->logPolicy($policy); - } - - /** - * Log error information. - * - * @param \Exception $err - * @param string ...$msg - * - * @return void - */ - public static function logError(\Exception $err, string ...$msg): void - { - self::$logger->logError($err, ...$msg); - } -} - -Log::setLogger(new DefaultLogger()); diff --git a/plugins/Authorization/Engine/Log/Logger/DefaultLogger.php b/plugins/Authorization/Engine/Log/Logger/DefaultLogger.php deleted file mode 100644 index da9cd9f..0000000 --- a/plugins/Authorization/Engine/Log/Logger/DefaultLogger.php +++ /dev/null @@ -1,205 +0,0 @@ -psrLogger = $psrLogger; - return; - } - $this->psrLogger = new class extends AbstractLogger { - public string $path = ''; - - public function __construct() - { - // GDA: no framework globals inside the engine. Resolve the log - // path from the kernel Paths helper, falling back to the system - // temp dir when it is unavailable (tests / standalone use). - $this->path = class_exists(\AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths::class) - ? \AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths::logs('casbin.log') - : sys_get_temp_dir() . '/casbin.log'; - } - - public function log($level, $message, array $context = []): void - { - $timestamp = date('Y-m-d H:i:s'); - $message = (string) $message; - foreach ($context as $key => $value) { - $message = str_replace("{{$key}}", (string) $value, $message); - } - $content = sprintf("[%s] %s: %s" . PHP_EOL, $timestamp, strtoupper($level), $message); - file_put_contents($this->path, $content, FILE_APPEND | LOCK_EX); - } - }; - } - - /** - * enableLog. - * - * @param bool $enable - */ - public function enableLog(bool $enable): void - { - $this->enabled = $enable; - } - - /** - * @return bool - */ - public function isEnabled(): bool - { - return $this->enabled; - } - - /** - * Log model information. - * - * @param array $model - * - * @return void - */ - public function logModel(array $model): void - { - if (!$this->enabled) { - return; - } - - $str = ''; - foreach ($model as $v) { - $str .= sprintf("%s " . PHP_EOL, '[' . implode(' ', $v) . ']'); - } - - $this->psrLogger->info('Model: {info}', ['info' => $str]); - } - - /** - * Log enforcer information. - * - * @param string $matcher - * @param array $request - * @param bool $result - * @param array $explains - * - * @return void - */ - public function logEnforce(string $matcher, array $request, bool $result, array $explains): void - { - if (!$this->enabled) { - return; - } - - $reqStr = implode(', ', array_values($request)); - $reqStr .= sprintf(" ---> %s" . PHP_EOL, var_export($result, true)); - - $hpStr = implode(', ', array_values($explains)); - if (count($explains) > 0) { - $hpStr .= PHP_EOL; - } - - $this->psrLogger->info('Request: {request}Hit Policy: {hitPolicy}', ['request' => $reqStr, 'hitPolicy' => $hpStr]); - } - - /** - * Log policy information. - * - * @param array $policy - * - * @return void - */ - public function logPolicy(array $policy): void - { - if (!$this->enabled) { - return; - } - - $str = ''; - foreach ($policy as $ptype => $ast) { - $str .= $ptype . ' : ['; - foreach ($ast as $rule) { - $str .= '[' . implode(' ', $rule) . '] '; - } - $str .= PHP_EOL; - } - if ($str !== '') { - $str = rtrim($str) . ']'; - } - - $this->psrLogger->info('Policy: {policy}', ['policy' => $str]); - } - - /** - * Log role information. - * - * @param array $roles - * - * @return void - */ - public function logRole(array $roles): void - { - if (!$this->enabled) { - return; - } - - $this->psrLogger->info('Roles: {roles}', ['roles' => implode(', ', $roles)]); - } - - /** - * Log error information. - * - * @param \Exception $err - * @param string ...$msg - * - * @return void - */ - public function logError(\Exception $err, string ...$msg): void - { - if (!$this->enabled) { - return; - } - - $errStr = $err->getMessage(); - - if (!empty($msg)) { - $errStr .= ' ' . implode(' ', $msg); - } - - $this->psrLogger->error($errStr); - } -} diff --git a/plugins/Authorization/Engine/ManagementEnforcer.php b/plugins/Authorization/Engine/ManagementEnforcer.php deleted file mode 100644 index 60013e7..0000000 --- a/plugins/Authorization/Engine/ManagementEnforcer.php +++ /dev/null @@ -1,775 +0,0 @@ -model->getValuesForFieldInPolicyAllTypesByName('p', Constants::SUBJECT_INDEX); - } - - /** - * Gets the list of subjects that show up in the current named policy. - * - * @param string $ptype - * - * @return array - */ - public function getAllNamedSubjects(string $ptype): array - { - $fieldIndex = $this->model->getFieldIndex('p', Constants::SUBJECT_INDEX); - return $this->model->getValuesForFieldInPolicy('p', $ptype, $fieldIndex); - } - - /** - * Gets the list of objects that show up in the current policy. - * - * @return array - */ - public function getAllObjects(): array - { - return $this->model->getValuesForFieldInPolicyAllTypesByName('p', Constants::OBJECT_INDEX); - } - - /** - * Gets the list of objects that show up in the current named policy. - * - * @param string $ptype - * - * @return array - */ - public function getAllNamedObjects(string $ptype): array - { - $fieldIndex = $this->model->getFieldIndex('p', Constants::OBJECT_INDEX); - return $this->model->getValuesForFieldInPolicy('p', $ptype, $fieldIndex); - } - - /** - * Gets the list of actions that show up in the current policy. - * - * @return array - */ - public function getAllActions(): array - { - return $this->model->getValuesForFieldInPolicyAllTypesByName('p', Constants::ACTION_INDEX); - } - - /** - * Gets the list of actions that show up in the current named policy. - * - * @param string $ptype - * - * @return array - */ - public function getAllNamedActions(string $ptype): array - { - $fieldIndex = $this->model->getFieldIndex('p', Constants::ACTION_INDEX); - return $this->model->getValuesForFieldInPolicy('p', $ptype, $fieldIndex); - } - - /** - * Gets the list of roles that show up in the current policy. - * - * @return array - */ - public function getAllRoles(): array - { - return $this->model->getValuesForFieldInPolicyAllTypes('g', 1); - } - - /** - * Gets the list of roles that show up in the current named policy. - * - * @param string $ptype - * - * @return array - */ - public function getAllNamedRoles(string $ptype): array - { - return $this->model->getValuesForFieldInPolicy('g', $ptype, 1); - } - - /** - * Gets all the authorization rules in the policy. - * - * @return array - */ - public function getPolicy(): array - { - return $this->getNamedPolicy('p'); - } - - /** - * Gets all the authorization rules in the policy, field filters can be specified. - * - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return array - */ - public function getFilteredPolicy(int $fieldIndex, string ...$fieldValues): array - { - return $this->getFilteredNamedPolicy('p', $fieldIndex, ...$fieldValues); - } - - /** - * Gets all the authorization rules in the named policy. - * - * @param string $ptype - * - * @return array - */ - public function getNamedPolicy(string $ptype): array - { - return $this->model->getPolicy('p', $ptype); - } - - /** - * Gets all the authorization rules in the named policy, field filters can be specified. - * - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return array - */ - public function getFilteredNamedPolicy(string $ptype, int $fieldIndex, string ...$fieldValues): array - { - return $this->model->getFilteredPolicy('p', $ptype, $fieldIndex, ...$fieldValues); - } - - /** - * Gets all the role inheritance rules in the policy. - * - * @return array - */ - public function getGroupingPolicy(): array - { - return $this->getNamedGroupingPolicy('g'); - } - - /** - * Gets all the role inheritance rules in the policy, field filters can be specified. - * - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return array - */ - public function getFilteredGroupingPolicy(int $fieldIndex, string ...$fieldValues): array - { - return $this->getFilteredNamedGroupingPolicy('g', $fieldIndex, ...$fieldValues); - } - - /** - * Gets all the role inheritance rules in the policy. - * - * @param string $ptype - * - * @return array - */ - public function getNamedGroupingPolicy(string $ptype): array - { - return $this->model->getPolicy('g', $ptype); - } - - /** - * Gets all the role inheritance rules in the policy, field filters can be specified. - * - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return array - */ - public function getFilteredNamedGroupingPolicy(string $ptype, int $fieldIndex, string ...$fieldValues): array - { - return $this->model->getFilteredPolicy('g', $ptype, $fieldIndex, ...$fieldValues); - } - - /** - * Determines whether an authorization rule exists. - * - * @param mixed ...$params - * - * @return bool - */ - public function hasPolicy(...$params): bool - { - return $this->hasNamedPolicy('p', ...$params); - } - - /** - * Determines whether a named authorization rule exists. - * - * @param string $ptype - * @param mixed ...$params - * - * @return bool - */ - public function hasNamedPolicy(string $ptype, ...$params): bool - { - if (1 == count($params) && is_array($params[0])) { - $params = $params[0]; - } - - return $this->model->hasPolicy('p', $ptype, $params); - } - - /** - * AddPolicy adds an authorization rule to the current policy. - * If the rule already exists, the function returns false and the rule will not be added. - * Otherwise the function returns true by adding the new rule. - * - * @param mixed ...$params - * - * @return bool - */ - public function addPolicy(...$params): bool - { - return $this->addNamedPolicy('p', ...$params); - } - - /** - * AddPolicies adds authorization rules to the current policy. - * If the rule already exists, the function returns false for the corresponding rule and the rule will not be added. - * Otherwise the function returns true for the corresponding rule by adding the new rule. - * - * @param string[][] $rules - * - * @return bool - * @throws Exceptions\CasbinException - */ - public function addPolicies(array $rules): bool - { - return $this->addNamedPolicies('p', $rules); - } - - /** - * AddPoliciesEx adds authorization rules to the current policy. - * If the rule already exists, the rule will not be added. - * But unlike AddPolicies, other non-existent rules are added instead of returning false directly. - * - * @param string[][] $rules - * - * @return bool - */ - public function addPoliciesEx(array $rules): bool - { - return $this->addNamedPoliciesEx('p', $rules); - } - - /** - * AddNamedPolicy adds an authorization rule to the current named policy. - * If the rule already exists, the function returns false and the rule will not be added. - * Otherwise the function returns true by adding the new rule. - * - * @param string $ptype - * @param mixed ...$params - * - * @return bool - */ - public function addNamedPolicy(string $ptype, ...$params): bool - { - if (1 == count($params) && is_array($params[0])) { - $params = $params[0]; - } - - return $this->addPolicyInternal('p', $ptype, $params); - } - - /** - * AddNamedPolicies adds authorization rules to the current named policy. - * If the rule already exists, the function returns false for the corresponding rule and the rule will not be added. - * Otherwise the function returns true for the corresponding by adding the new rule. - * - * @param string $ptype - * @param string[][] $rules - * - * @return bool - * @throws Exceptions\CasbinException - */ - public function addNamedPolicies(string $ptype, array $rules): bool - { - return $this->addPoliciesInternal('p', $ptype, $rules, false); - } - - /** - * AddNamedPoliciesEx adds authorization rules to the current named policy. - * If the rule already exists, the rule will not be added. - * But unlike AddNamedPolicies, other non-existent rules are added instead of returning false directly. - * - * @param string $ptype - * @param string[][] $rules - * - * @return bool - */ - public function addNamedPoliciesEx(string $ptype, array $rules): bool - { - return $this->addPoliciesInternal('p', $ptype, $rules, true); - } - - /** - * Removes an authorization rule from the current policy. - * - * @param mixed ...$params - * - * @return bool - */ - public function removePolicy(...$params): bool - { - return $this->removeNamedPolicy('p', ...$params); - } - - /** - * Removes an authorization rules from the current policy. - * - * @param array $rules - * - * @return bool - */ - public function removePolicies(array $rules): bool - { - return $this->removeNamedPolicies('p', $rules); - } - - /** - * Removes an authorization rule from the current policy. - * - * @param string[] $oldRule - * @param string[] $newRule - * - * @return bool - */ - public function updatePolicy(array $oldRule, array $newRule): bool - { - return $this->updateNamedPolicy("p", $oldRule, $newRule); - } - - /** - * Updates an authorization rule from the current policy. - * - * @param string $ptype - * @param string[] $oldRule - * @param string[] $newRule - * - * @return bool - */ - public function updateNamedPolicy(string $ptype, array $oldRule, array $newRule): bool - { - return $this->updatePolicyInternal("p", $ptype, $oldRule, $newRule); - } - - /** - * UpdatePolicies updates authorization rules from the current policies. - * - * @param string[][] $oldPolices - * @param string[][] $newPolicies - * @return boolean - */ - public function updatePolicies(array $oldPolices, array $newPolicies): bool - { - return $this->updateNamedPolicies("p", $oldPolices, $newPolicies); - } - - /** - * Updates authorization rules from the current policy. - * - * @param string $ptype - * @param string[][] $oldPolices - * @param string[][] $newPolicies - * @return boolean - */ - public function updateNamedPolicies(string $ptype, array $oldPolices, array $newPolicies): bool - { - return $this->updatePoliciesInternal("p", $ptype, $oldPolices, $newPolicies); - } - - public function updateFilteredPolicies(array $newPolicies, int $fieldIndex, string ...$fieldValues): bool - { - return $this->updateFilteredNamedPolicies("p", $newPolicies, $fieldIndex, ...$fieldValues); - } - - /** - * Undocumented function - * - * @param string $ptype - * @param array $newPolicies - * @param integer $fieldIndex - * @param string ...$fieldValues - * @return boolean - */ - public function updateFilteredNamedPolicies(string $ptype, array $newPolicies, int $fieldIndex, string ...$fieldValues): bool - { - return $this->updateFilteredPoliciesInternal("p", $ptype, $newPolicies, $fieldIndex, ...$fieldValues); - } - - /** - * Removes an authorization rule from the current policy, field filters can be specified. - * - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return bool - */ - public function removeFilteredPolicy(int $fieldIndex, string ...$fieldValues): bool - { - return $this->removeFilteredNamedPolicy('p', $fieldIndex, ...$fieldValues); - } - - /** - * Removes an authorization rule from the current named policy. - * - * @param string $ptype - * @param mixed ...$params - * - * @return bool - */ - public function removeNamedPolicy(string $ptype, ...$params): bool - { - if (1 == count($params) && is_array($params[0])) { - $params = $params[0]; - } - - return $this->removePolicyInternal('p', $ptype, $params); - } - - /** - * Removes an authorization rules from the current named policy. - * - * @param string $ptype - * @param array $rules - * - * @return bool - */ - public function removeNamedPolicies(string $ptype, array $rules): bool - { - return $this->removePoliciesInternal('p', $ptype, $rules); - } - - /** - * Removes an authorization rule from the current named policy, field filters can be specified. - * - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return bool - */ - public function removeFilteredNamedPolicy(string $ptype, int $fieldIndex, string ...$fieldValues): bool - { - return $this->removeFilteredPolicyInternal('p', $ptype, $fieldIndex, ...$fieldValues); - } - - /** - * Determines whether a role inheritance rule exists. - * - * @param mixed ...$params - * - * @return bool - */ - public function hasGroupingPolicy(...$params): bool - { - return $this->hasNamedGroupingPolicy('g', ...$params); - } - - /** - * Determines whether a named role inheritance rule exists. - * - * @param string $ptype - * @param mixed ...$params - * - * @return bool - */ - public function hasNamedGroupingPolicy(string $ptype, ...$params): bool - { - if (1 == count($params) && is_array($params[0])) { - $params = $params[0]; - } - - return $this->model->hasPolicy('g', $ptype, $params); - } - - /** - * AddGroupingPolicy adds a role inheritance rule to the current policy. - * If the rule already exists, the function returns false and the rule will not be added. - * Otherwise the function returns true by adding the new rule. - * - * @param mixed ...$params - * - * @return bool - */ - public function addGroupingPolicy(...$params): bool - { - return $this->addNamedGroupingPolicy('g', ...$params); - } - - /** - * AddGroupingPolicy adds a role inheritance rules to the current policy. - * If the rule already exists, the function returns false and the rule will not be added. - * Otherwise the function returns true by adding the new rule. - * - * @param array $rules - * - * @return bool - */ - public function addGroupingPolicies(array $rules): bool - { - return $this->addNamedGroupingPolicies('g', $rules); - } - - /** - * AddGroupingPolicyEx adds a role inheritance rules to the current policy. - * If the rule already exists, the rule will not be added. - * But unlike AddGroupingPolicy, other non-existent rules are added instead of returning false directly. - * - * @param array $rules - * - * @return bool - */ - public function addGroupingPoliciesEx(array $rules): bool - { - return $this->addNamedGroupingPoliciesEx('g', $rules); - } - - /** - * AddNamedGroupingPolicy adds a named role inheritance rule to the current policy. - * If the rule already exists, the function returns false and the rule will not be added. - * Otherwise the function returns true by adding the new rule. - * - * @param string $ptype - * @param mixed ...$params - * - * @return bool - */ - public function addNamedGroupingPolicy(string $ptype, ...$params): bool - { - if (1 == count($params) && is_array($params[0])) { - $params = $params[0]; - } - - $ruleAdded = $this->addPolicyInternal('g', $ptype, $params); - - return $ruleAdded; - } - - /** - * AddNamedGroupingPolicy adds a named role inheritance rules to the current policy. - * If the rule already exists, the function returns false and the rule will not be added. - * Otherwise the function returns true by adding the new rule. - * - * @param string $ptype - * @param array $rules - * - * @return bool - */ - public function addNamedGroupingPolicies(string $ptype, array $rules): bool - { - return $this->addPoliciesInternal('g', $ptype, $rules, false); - } - - public function addNamedGroupingPoliciesEx(string $ptype, array $rules): bool - { - return $this->addPoliciesInternal('g', $ptype, $rules, true); - } - - /** - * Removes a role inheritance rule from the current policy. - * - * @param mixed ...$params - * - * @return bool - */ - public function removeGroupingPolicy(...$params): bool - { - return $this->removeNamedGroupingPolicy('g', ...$params); - } - - /** - * Removes a role inheritance rules from the current policy. - * - * @param array $rules - * - * @return bool - */ - public function removeGroupingPolicies(array $rules): bool - { - return $this->removeNamedGroupingPolicies('g', $rules); - } - - /** - * Removes a role inheritance rule from the current policy, field filters can be specified. - * - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return bool - */ - public function removeFilteredGroupingPolicy(int $fieldIndex, string ...$fieldValues): bool - { - return $this->removeFilteredNamedGroupingPolicy('g', $fieldIndex, ...$fieldValues); - } - - /** - * Removes a role inheritance rule from the current named policy. - * - * @param string $ptype - * @param mixed ...$params - * - * @return bool - */ - public function removeNamedGroupingPolicy(string $ptype, ...$params): bool - { - if (1 == count($params) && is_array($params[0])) { - $params = $params[0]; - } - - $ruleRemoved = $this->removePolicyInternal('g', $ptype, $params); - - if ($this->autoBuildRoleLinks) { - $this->buildRoleLinks(); - } - - return $ruleRemoved; - } - - /** - * Removes a role inheritance rules from the current named policy. - * - * @param string $ptype - * @param array $rules - * - * @return bool - */ - public function removeNamedGroupingPolicies(string $ptype, array $rules): bool - { - $ruleRemoved = $this->removePoliciesInternal('g', $ptype, $rules); - - if ($this->autoBuildRoleLinks) { - $this->buildRoleLinks(); - } - - return $ruleRemoved; - } - - /** - * Removes a role inheritance rule from the current named policy, field filters can be specified. - * - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return bool - */ - public function removeFilteredNamedGroupingPolicy(string $ptype, int $fieldIndex, string ...$fieldValues): bool - { - $ruleRemoved = $this->removeFilteredPolicyInternal('g', $ptype, $fieldIndex, ...$fieldValues); - - if ($this->autoBuildRoleLinks) { - $this->buildRoleLinks(); - } - - return $ruleRemoved; - } - - /** - * Adds a customized function. - * - * @param string $name - * @param Closure $func - */ - public function addFunction(string $name, Closure $func): void - { - $this->fm->addFunction($name, $func); - } - - /** - * Adds authorization rule to the current policy. - * If the rule already exists, the function returns false and the rule will not be added. - * Otherwise the function returns true by adding the new rule. - * - * @param string $sec - * @param string $ptype - * @param string[] $params - * - * @return void - */ - public function selfAddPolicy(string $sec, string $ptype, array $params): void - { - $this->addPolicyWithoutNotifyInternal($sec, $ptype, $params); - } - - /** - * Adds authorization rules to the current policy. - * If the rule already exists, the function returns false for the corresponding rule and the rule will not be added. - * Otherwise the function returns true for the corresponding rule by adding the new rule. - * - * @param string $sec - * @param string $ptype - * @param string[][] $params - * - * @return bool - */ - public function selfAddPolices(string $sec, string $ptype, array $params): bool - { - return $this->addPoliciesWithoutNotifyInternal($sec, $ptype, $params, false); - } - - /** - * Adds authorization rules to the current named policy with autoNotifyWatcher disabled. - * If the rule already exists, the rule will not be added. - * But unlike SelfAddPolicies, other non-existent rules are added instead of returning false directly - * - * @param string $sec - * @param string $ptype - * @param string[][] $params - * - * @return bool - */ - public function selfAddPolicesEx(string $sec, string $ptype, array $params): bool - { - return $this->addPoliciesWithoutNotifyInternal($sec, $ptype, $params, true); - } - - /** - * Gets the index for a given ptype and field. - * - * @param string $ptype - * @param string $field - * - * @return int $fieldIndex - * @throws Exceptions\CasbinException - */ - public function getFieldIndex(string $ptype, string $field): int - { - return $this->model->getFieldIndex($ptype, $field); - } - - /** - * Sets the index for a given ptype and field. - * - * @param string $ptype - * @param string $field - * @param int $index - */ - public function setFieldIndex(string $ptype, string $field, int $index): void - { - $this->model->setFieldIndex($ptype, $field, $index); - } -} diff --git a/plugins/Authorization/Engine/Model/Assertion.php b/plugins/Authorization/Engine/Model/Assertion.php deleted file mode 100644 index b5fca6d..0000000 --- a/plugins/Authorization/Engine/Model/Assertion.php +++ /dev/null @@ -1,240 +0,0 @@ - - */ - public array $policyMap = []; - - /** - * $rm. - * - * @var RoleManager|null - */ - public ?RoleManager $rm = null; - - /** - * $condRmMap - * - * @var ConditionalRoleManager|null - */ - public ?ConditionalRoleManager $condRm = null; - - /** - * $fieldIndexMap - * - * @var array - */ - public array $fieldIndexMap = []; - - /** - * $logger. - * - * @var Logger|null - */ - public ?Logger $logger = null; - - /** - * Sets the current logger. - * - * @param Logger $logger - * - * @return void - */ - public function setLogger($logger): void - { - $this->logger = $logger; - } - - /** - * @param RoleManager $rm - * - * @throws CasbinException - */ - public function buildRoleLinks(RoleManager $rm): void - { - $this->rm = $rm; - $count = substr_count($this->value, '_'); - if ($count < 2) { - throw new CasbinException('the number of "_" in role definition should be at least 2'); - } - - foreach ($this->policy as $rule) { - if (count($rule) < $count) { - throw new CasbinException('grouping policy elements do not meet role definition'); - } - if (count($rule) > $count) { - $rule = array_slice($rule, 0, $count); - } - - $this->rm->addLink($rule[0], $rule[1], ...array_slice($rule, 2)); - } - } - - /** - * @param RoleManager $rm - * @param integer $op - * @param string[][] $rules - * - * @return void - * - * @throws CasbinException - */ - public function buildIncrementalRoleLinks(RoleManager $rm, int $op, array $rules): void - { - $this->rm = $rm; - $count = substr_count($this->value, '_'); - if ($count < 2) { - throw new CasbinException('the number of "_" in role definition should be at least 2'); - } - - foreach ($rules as $rule) { - if (count($rule) < $count) { - throw new CasbinException('grouping policy elements do not meet role definition'); - } - if (count($rule) > $count) { - $rule = array_slice($rule, 0, $count); - } - match ($op) { - Policy::POLICY_ADD => $this->rm->addLink($rule[0], $rule[1], ...array_slice($rule, 2)), - Policy::POLICY_REMOVE => $this->rm->deleteLink($rule[0], $rule[1], ...array_slice($rule, 2)), - default => throw new CasbinException('invalid policy operation') - }; - } - } - - /** - * @param ConditionalRoleManager $condRm - * - * @return void - * - * @throws CasbinException - */ - public function buildConditionalRoleLinks(ConditionalRoleManager $condRm): void - { - $this->condRm = $condRm; - $count = substr_count($this->value, '_'); - if ($count < 2) { - throw new CasbinException('the number of "_" in role definition should be at least 2'); - } - - foreach ($this->policy as $rule) { - if (count($rule) < $count) { - throw new CasbinException('grouping policy elements do not meet role definition'); - } - if (count($rule) > $count) { - $rule = array_slice($rule, 0, $count); - } - - $domainRule = array_slice($rule, 2, count($this->tokens) - 2); - - $this->addConditionalRoleLink($rule, $domainRule); - } - } - - /** - * @param ConditionalRoleManager $condRm - * @param integer $op - * @param string[][] $rules - * - * @return void - * - * @throws CasbinException - */ - public function buildIncrementalConditionalRoleLinks(ConditionalRoleManager $condRm, int $op, array $rules): void - { - $this->condRm = $condRm; - $count = substr_count($this->value, '_'); - if ($count < 2) { - throw new CasbinException('the number of "_" in role definition should be at least 2'); - } - - foreach ($rules as $rule) { - if (count($rule) < $count) { - throw new CasbinException('grouping policy elements do not meet role definition'); - } - if (count($rule) > $count) { - $rule = array_slice($rule, 0, $count); - } - - $domainRule = array_slice($rule, 2, count($this->tokens) - 2); - - match ($op) { - Policy::POLICY_ADD => $this->addConditionalRoleLink($rule, $domainRule), - Policy::POLICY_REMOVE => $this->condRm->deleteLink($rule[0], $rule[1], ...array_slice($rule, 2)), - default => throw new CasbinException('invalid policy operation') - }; - } - } - - /** - * @param array $rule - * @param array $domainRule - * - * @return void - */ - public function addConditionalRoleLink(array $rule, array $domainRule): void - { - if (count($domainRule) === 0) { - $this->condRm->addLink($rule[0], $rule[1]); - $this->condRm->setLinkConditionFuncParams($rule[0], $rule[1], ...array_slice($rule, count($this->tokens))); - } else { - $domain = $domainRule[0]; - $this->condRm->addLink($rule[0], $rule[1], $domain); - $this->condRm->setDomainLinkConditionFuncParams($rule[0], $rule[1], $domain, ...array_slice($rule, count($this->tokens))); - } - } -} diff --git a/plugins/Authorization/Engine/Model/FunctionMap.php b/plugins/Authorization/Engine/Model/FunctionMap.php deleted file mode 100644 index 33fd106..0000000 --- a/plugins/Authorization/Engine/Model/FunctionMap.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ - private array $functions = []; - - /** - * @param string $name - * @param Closure $func - */ - public function addFunction(string $name, Closure $func): void - { - - $this->functions[$name] = $func; - } - - /** - * Loads an initial function map. - * - * @return FunctionMap - */ - public static function loadFunctionMap(): self - { - $fm = new self(); - - $fm->addFunction('keyMatch', fn(...$args) => BuiltinOperations::keyMatchFunc(...$args)); - $fm->addFunction('keyGet', fn(...$args) => BuiltinOperations::keyGetFunc(...$args)); - $fm->addFunction('keyMatch2', fn(...$args) => BuiltinOperations::keyMatch2Func(...$args)); - $fm->addFunction('keyGet2', fn(...$args) => BuiltinOperations::keyGet2Func(...$args)); - $fm->addFunction('keyMatch3', fn(...$args) => BuiltinOperations::keyMatch3Func(...$args)); - $fm->addFunction('keyMatch4', fn(...$args) => BuiltinOperations::keyMatch4Func(...$args)); - $fm->addFunction('keyMatch5', fn(...$args) => BuiltinOperations::keyMatch5Func(...$args)); - $fm->addFunction('regexMatch', fn(...$args) => BuiltinOperations::regexMatchFunc(...$args)); - $fm->addFunction('ipMatch', fn(...$args) => BuiltinOperations::ipMatchFunc(...$args)); - $fm->addFunction('globMatch', fn(...$args) => BuiltinOperations::globMatchFunc(...$args)); - - return $fm; - } - - /** - * @return array - */ - public function getFunctions(): array - { - return $this->functions; - } -} diff --git a/plugins/Authorization/Engine/Model/Model.php b/plugins/Authorization/Engine/Model/Model.php deleted file mode 100644 index 4eb7f66..0000000 --- a/plugins/Authorization/Engine/Model/Model.php +++ /dev/null @@ -1,389 +0,0 @@ - - */ - protected array $sectionNameMap = [ - 'r' => 'request_definition', - 'p' => 'policy_definition', - 'g' => 'role_definition', - 'e' => 'policy_effect', - 'm' => 'matchers', - ]; - - /** - * @var string - */ - protected string $paramsRegex = '/\((.*?)\)/'; - - public function __construct() - { - $this->setLogger(new DefaultLogger()); - } - - public function __clone() - { - $this->sectionNameMap = $this->sectionNameMap; - $newAstMap = []; - foreach ($this->items as $ptype => $ast) { - foreach ($ast as $i => $v) { - $newAstMap[$ptype][$i] = clone $v; - } - } - $this->items = $newAstMap; - } - - /** - * @param ConfigInterface $cfg - * @param string $sec - * @param string $key - * - * @return bool - * @throws CasbinException - */ - private function loadAssertion(ConfigInterface $cfg, string $sec, string $key): bool - { - $value = $cfg->getString($this->sectionNameMap[$sec] . '::' . $key); - - return $this->addDef($sec, $key, $value); - } - - /** - * Get ParamsToken from Assertion.Value - * - * @param string $value - * - * @return array - */ - private function getParamsToken(string $value): array - { - if (!preg_match($this->paramsRegex, $value, $paramsString)) { - return []; - }; - $paramsString = trim(substr($paramsString[0], 1, -1)); - return explode(',', $paramsString); - } - - /** - * Adds an assertion to the model. - * - * @param string $sec - * @param string $key - * @param string $value - * - * @return bool - * @throws CasbinException - */ - public function addDef(string $sec, string $key, string $value): bool - { - if ('' == $value) { - return false; - } - - $ast = new Assertion(); - $ast->key = $key; - $ast->value = $value; - - if ('r' == $sec || 'p' == $sec) { - $ast->tokens = explode(',', $ast->value); - foreach ($ast->tokens as $i => $token) { - $ast->tokens[$i] = $key . '_' . trim($token); - } - } else if ('g' == $sec) { - $ast->paramsTokens = $this->getParamsToken($ast->value); - $ast->tokens = explode(',', $ast->value); - $ast->tokens = array_slice($ast->tokens, 0, count($ast->tokens) - count($ast->paramsTokens)); - } else { - $ast->value = stripInlineComments(escapeDotsInAssertion($ast->value)); - } - - $this->items[$sec][$key] = $ast; - - return true; - } - - /** - * @param int $i - * - * @return string - */ - private function getKeySuffix(int $i): string - { - if (1 == $i) { - return ''; - } - - return (string)$i; - } - - /** - * @param ConfigInterface $cfg - * @param string $sec - * @throws CasbinException - */ - private function loadSection(ConfigInterface $cfg, string $sec): void - { - $i = 1; - for (;;) { - if (!$this->loadAssertion($cfg, $sec, $sec . $this->getKeySuffix($i))) { - break; - } else { - ++$i; - } - } - } - - /** - * Creates an empty model. - * - * @return Model - */ - public static function newModel(): self - { - return new self(); - } - - /** - * Creates a model from a .CONF file. - * - * @param string $path - * - * @return Model - * @throws CasbinException - */ - public static function newModelFromFile(string $path): self - { - $m = self::newModel(); - - $m->loadModel($path); - - return $m; - } - - /** - * Creates a model from a string which contains model text. - * - * @param string $text - * - * @return Model - * @throws CasbinException - */ - public static function newModelFromString(string $text): self - { - $m = self::newModel(); - - $m->loadModelFromText($text); - - return $m; - } - - /** - * Loads the model from model CONF file. - * - * @param string $path - * @throws CasbinException - */ - public function loadModel(string $path): void - { - $cfg = Config::newConfig($path); - - $this->loadSection($cfg, 'r'); - $this->loadSection($cfg, 'p'); - $this->loadSection($cfg, 'e'); - $this->loadSection($cfg, 'm'); - - $this->loadSection($cfg, 'g'); - } - - /** - * Loads the model from the text. - * - * @param string $text - * @throws CasbinException - */ - public function loadModelFromText(string $text): void - { - $cfg = Config::newConfigFromText($text); - - $this->loadSection($cfg, 'r'); - $this->loadSection($cfg, 'p'); - $this->loadSection($cfg, 'e'); - $this->loadSection($cfg, 'm'); - - $this->loadSection($cfg, 'g'); - } - - /** - * Prints the model to the log. - */ - public function printModel(): void - { - if (!$this->getLogger()->isEnabled()) { - return; - } - - $modelInfo = []; - foreach ($this->items as $sec => $astMap) { - foreach ($astMap as $key => $ast) { - $modelInfo[] = [$sec, $key, $ast->value]; - } - } - - $this->getLogger()->logModel($modelInfo); - } - - /** - * Loads an initial function map. - * - * @return FunctionMap - */ - public static function loadFunctionMap(): FunctionMap - { - return FunctionMap::loadFunctionMap(); - } - - public function getNameWithDomain(string $domain, string $name): string - { - return $domain . self::DEFAULT_SEPARATOR . $name; - } - - public function getSubjectHierarchyMap(array $policies): array - { - $subjectHierarchyMap = []; - // Tree structure of role - $policyMap = []; - foreach ($policies as $policy) { - if (count($policy) < 2) { - throw new CasbinException('policy g expect 2 more params'); - } - $domain = self::DEFAULT_DOMAIN; - if (count($policy) != 2) { - $domain = $policy[2]; - } - $child = $this->getNameWithDomain($domain, $policy[0]); - $parent = $this->getNameWithDomain($domain, $policy[1]); - $policyMap[$parent][] = $child; - if (!isset($subjectHierarchyMap[$child])) { - $subjectHierarchyMap[$child] = 0; - } - if (!isset($subjectHierarchyMap[$parent])) { - $subjectHierarchyMap[$parent] = 0; - } - $subjectHierarchyMap[$child] = 1; - } - // Use queues for levelOrder - $queue = []; - foreach ($subjectHierarchyMap as $k => $v) { - $root = $k; - if ($v != 0) { - continue; - } - $lv = 0; - $queue[] = $root; - while (count($queue) != 0) { - $sz = count($queue); - for ($i = 0; $i < $sz; $i++) { - $node = $queue[array_key_first($queue)]; - unset($queue[array_key_first($queue)]); - - $nodeValue = $node; - $subjectHierarchyMap[$nodeValue] = $lv; - if (isset($policyMap[$nodeValue])) { - foreach ($policyMap[$nodeValue] as $child) { - $queue[] = $child; - } - } - } - $lv++; - } - } - - return $subjectHierarchyMap; - } - - public function sortPoliciesBySubjectHierarchy(): void - { - if ($this->items['e']['e']->value != Constants::SUBJECT_PRIORITY_EFFECT) { - return; - } - $subIndex = 0; - - foreach ($this->items['p'] as $ptype => $assertion) { - try { - $domainIndex = $this->getFieldIndex($ptype, Constants::DOMAIN_INDEX); - } catch (CasbinException) { - $domainIndex = -1; - } - $policies = &$assertion->policy; - $subjectHierarchyMap = $this->getSubjectHierarchyMap($this->items['g']['g']->policy); - - usort($policies, function ($i, $j) use ($subIndex, $domainIndex, $subjectHierarchyMap): int { - $domain1 = self::DEFAULT_DOMAIN; - $domain2 = self::DEFAULT_DOMAIN; - if ($domainIndex != -1) { - $domain1 = $i[$domainIndex]; - $domain2 = $j[$domainIndex]; - } - $name1 = $this->getNameWithDomain($domain1, $i[$subIndex]); - $name2 = $this->getNameWithDomain($domain2, $j[$subIndex]); - - $p1 = $subjectHierarchyMap[$name1] ?? 0; - $p2 = $subjectHierarchyMap[$name2] ?? 0; - - if ($p1 == $p2) { - return 0; - } - return ($p1 > $p2) ? -1 : 1; - }); - - foreach ($assertion->policy as $i => $policy) { - $assertion->policyMap[implode(',', $policy)] = $i; - } - } - } - - public function sortPoliciesByPriority(): void - { - foreach ($this->items['p'] as $ptype => $assertion) { - try { - $priorityIndex = $this->getFieldIndex($ptype, Constants::PRIORITY_INDEX); - } catch (CasbinException) { - continue; - } - $policies = &$assertion->policy; - usort($policies, function ($i, $j) use ($priorityIndex): int { - $p1 = $i[$priorityIndex]; - $p2 = $j[$priorityIndex]; - if ($p1 == $p2) { - return 0; - } - return ($p1 < $p2) ? -1 : 1; - }); - foreach ($assertion->policy as $i => $policy) { - $assertion->policyMap[implode(',', $policy)] = $i; - } - } - } -} diff --git a/plugins/Authorization/Engine/Model/Policy.php b/plugins/Authorization/Engine/Model/Policy.php deleted file mode 100644 index 6532090..0000000 --- a/plugins/Authorization/Engine/Model/Policy.php +++ /dev/null @@ -1,667 +0,0 @@ -> - * @author techlee@qq.com - */ -abstract class Policy implements ArrayAccess -{ - public const POLICY_ADD = 0; - - public const POLICY_REMOVE = 1; - - const DEFAULT_SEP = ","; - - /** - * All of the Model items. - * - * @var array> - */ - protected array $items = []; - - /** - * $logger. - * - * @var Logger|null - */ - protected ?Logger $logger = null; - - /** - * BuildIncrementalRoleLinks provides incremental build the role inheritance relations. - * - * @param RoleManager[] $rmMap - * @param integer $op - * @param string $sec - * @param string $ptype - * @param string[][] $rules - * @return void - */ - public function buildIncrementalRoleLinks(array $rmMap, int $op, string $sec, string $ptype, array $rules): void - { - if ($sec == "g" && isset($rmMap[$ptype]) && isset($this->items[$sec][$ptype])) { - $this->items[$sec][$ptype]->buildIncrementalRoleLinks($rmMap[$ptype], $op, $rules); - } - } - - /** - * Initializes the roles in RBAC. - * - * @param RoleManager[] $rmMap - * @throws CasbinException - */ - public function buildRoleLinks(array $rmMap): void - { - $this->printPolicy(); - if (!isset($this->items['g'])) { - return; - } - - foreach ($this->items['g'] as $ptype => $ast) { - if (isset($rmMap[$ptype])) { - $rm = $rmMap[$ptype]; - $ast->buildRoleLinks($rm); - } - } - } - - /** - * BuildIncrementalConditionalRoleLinks provides incremental build the role inheritance relations. - * - * @param ConditionalRoleManager[] $condRmMap - * @param integer $op - * @param string $sec - * @param string $ptype - * @param string[][] $rules - * @return void - */ - public function buildIncrementalConditionalRoleLinks(array $condRmMap, int $op, string $sec, string $ptype, array $rules): void - { - if ($sec == "g" && isset($condRmMap[$ptype]) && isset($this->items[$sec][$ptype])) { - $this->items[$sec][$ptype]->buildIncrementalConditionalRoleLinks($condRmMap[$ptype], $op, $rules); - } - } - - /** - * Initializes the roles in RBAC with conditions. - * - * @param ConditionalRoleManager[] $condRmMap - * @throws CasbinException - */ - public function buildConditionalRoleLinks(array $condRmMap): void - { - $this->printPolicy(); - if (!isset($this->items['g'])) { - return; - } - - foreach ($this->items['g'] as $ptype => $ast) { - if (isset($condRmMap[$ptype])) { - $rm = $condRmMap[$ptype]; - $ast->buildConditionalRoleLinks($rm); - } - } - } - - /** - * Prints the policy to log. - */ - public function printPolicy(): void - { - if (!$this->getLogger()->isEnabled()) { - return; - } - - $policy = []; - foreach (['p', 'g'] as $sec) { - if (!isset($this->items[$sec])) { - continue; - } - - foreach ($this->items[$sec] as $ptype => $ast) { - $policy[$ptype] = array_merge( - $policy[$ptype] ?? [], - $ast->policy - ); - } - } - - $this->getLogger()->logPolicy($policy); - } - - /** - * Clears all current policy. - */ - public function clearPolicy(): void - { - foreach (['p', 'g'] as $sec) { - if (!isset($this->items[$sec])) { - return; - } - - foreach ($this->items[$sec] as $key => $ast) { - $this->items[$sec][$key]->policy = []; - $this->items[$sec][$key]->policyMap = []; - } - } - } - - /** - * Gets all rules in a policy. - * - * @param string $sec - * @param string $ptype - * - * @return string[][] - */ - public function getPolicy(string $sec, string $ptype): array - { - return $this->items[$sec][$ptype]->policy; - } - - /** - * Gets rules based on field filters from a policy. - * - * @param string $sec - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @return string[][] - */ - public function getFilteredPolicy(string $sec, string $ptype, int $fieldIndex, string ...$fieldValues): array - { - $res = []; - - foreach ($this->items[$sec][$ptype]->policy as $rule) { - $matched = true; - foreach ($fieldValues as $i => $fieldValue) { - if ('' != $fieldValue && $rule[$fieldIndex + intval($i)] != $fieldValue) { - $matched = false; - - break; - } - } - - if ($matched) { - $res[] = $rule; - } - } - - return $res; - } - - /** - * Determines whether a model has the specified policy rule. - * - * @param string $sec - * @param string $ptype - * @param string[] $rule - * - * @return bool - */ - public function hasPolicy(string $sec, string $ptype, array $rule): bool - { - if (!isset($this->items[$sec][$ptype])) { - return false; - } - - return isset($this->items[$sec][$ptype]->policyMap[implode(self::DEFAULT_SEP, $rule)]); - } - - /** - * Determines whether a model has any of the specified policies. If one is found we return true. - * - * @param string $sec - * @param string $ptype - * @param string[][] $rules - * - * @return bool - */ - public function hasPolicies(string $sec, string $ptype, array $rules): bool - { - foreach ($rules as $rule) { - if ($this->hasPolicy($sec, $ptype, $rule)) { - return true; - } - } - - return false; - } - - /** - * Adds a policy rule to the model. - * - * @param string $sec - * @param string $ptype - * @param string[] $rule - */ - public function addPolicy(string $sec, string $ptype, array $rule): void - { - $assertion = &$this->items[$sec][$ptype]; - $assertion->policy[] = $rule; - $assertion->policyMap[implode(self::DEFAULT_SEP, $rule)] = count($this->items[$sec][$ptype]->policy) - 1; - - $hasPriority = isset($assertion->fieldIndexMap[Constants::PRIORITY_INDEX]); - if ($sec == 'p' && $hasPriority) { - $idxInsert = $rule[$assertion->fieldIndexMap[Constants::PRIORITY_INDEX]]; - for ($i = count($assertion->policy) - 1; $i > 0; $i--) { - $idx = $assertion->policy[$i - 1][$assertion->fieldIndexMap[Constants::PRIORITY_INDEX]]; - if ($idx > $idxInsert) { - $assertion->policy[$i] = $assertion->policy[$i - 1]; - $assertion->policyMap[implode(self::DEFAULT_SEP, $assertion->policy[$i - 1])]++; - } else { - break; - } - } - $assertion->policy[$i] = $rule; - $assertion->policyMap[implode(self::DEFAULT_SEP, $rule)] = $i; - } - } - - /** - * Adds a policy rules to the model. - * - * @param string $sec - * @param string $ptype - * @param string[][] $rules - */ - public function addPolicies(string $sec, string $ptype, array $rules): void - { - $this->addPoliciesWithAffected($sec, $ptype, $rules); - } - - /** - * Adds policy rules to the model, and returns affected rules. - * - * @param string $sec - * @param string $ptype - * @param string[][] $rules - * - * @return string[][] - */ - public function addPoliciesWithAffected(string $sec, string $ptype, array $rules): array - { - $affected = []; - - foreach ($rules as $rule) { - $hashKey = implode(self::DEFAULT_SEP, $rule); - if (isset($this->items[$sec][$ptype]->policyMap[$hashKey])) { - continue; - } - - $affected[] = $rule; - $this->addPolicy($sec, $ptype, $rule); - } - - return $affected; - } - - /** - * Updates a policy rule from the model. - * - * @param string $sec - * @param string $ptype - * @param string[] $oldRule - * @param string[] $newRule - * - * @return bool - */ - public function updatePolicy(string $sec, string $ptype, array $oldRule, array $newRule): bool - { - $oldPolicy = implode(self::DEFAULT_SEP, $oldRule); - if (!isset($this->items[$sec][$ptype]->policyMap[$oldPolicy])) { - return false; - } - - $index = $this->items[$sec][$ptype]->policyMap[$oldPolicy]; - $this->items[$sec][$ptype]->policy[$index] = $newRule; - unset($this->items[$sec][$ptype]->policyMap[$oldPolicy]); - $this->items[$sec][$ptype]->policyMap[implode(self::DEFAULT_SEP, $newRule)] = $index; - - return true; - } - - /** - * UpdatePolicies updates a policy rule from the model. - * - * @param string $sec - * @param string $ptype - * @param string[][] $oldRules - * @param string[][] $newRules - * @return boolean - */ - public function updatePolicies(string $sec, string $ptype, array $oldRules, array $newRules): bool - { - $modifiedRuleIndex = []; - - $newIndex = 0; - foreach ($oldRules as $oldIndex => $oldRule) { - $oldPolicy = implode(self::DEFAULT_SEP, $oldRule); - $index = $this->items[$sec][$ptype]->policyMap[$oldPolicy] ?? null; - if (is_null($index)) { - // rollback - foreach ($modifiedRuleIndex as $index => $oldNewIndex) { - $this->items[$sec][$ptype]->policy[$index] = $oldRules[$oldNewIndex[0]]; - $oldPolicy = implode(self::DEFAULT_SEP, $oldRules[$oldNewIndex[0]]); - $newPolicy = implode(self::DEFAULT_SEP, $newRules[$oldNewIndex[1]]); - unset($this->items[$sec][$ptype]->policyMap[$newPolicy]); - $this->items[$sec][$ptype]->policyMap[$oldPolicy] = $index; - } - return false; - } - - $this->items[$sec][$ptype]->policy[$index] = $newRules[$newIndex]; - unset($this->items[$sec][$ptype]->policyMap[$oldPolicy]); - $this->items[$sec][$ptype]->policyMap[implode(self::DEFAULT_SEP, $newRules[$newIndex])] = $index; - $modifiedRuleIndex[$index] = [$oldIndex, $newIndex]; - $newIndex++; - } - - return true; - } - - /** - * Removes a policy rule from the model. - * - * @param string $sec - * @param string $ptype - * @param array $rule - * - * @return bool - */ - public function removePolicy(string $sec, string $ptype, array $rule): bool - { - if (!isset($this->items[$sec][$ptype])) { - return false; - } - - $hashKey = implode(self::DEFAULT_SEP, $rule); - if (!isset($this->items[$sec][$ptype]->policyMap[$hashKey])) { - return false; - } - - $index = $this->items[$sec][$ptype]->policyMap[$hashKey]; - array_splice($this->items[$sec][$ptype]->policy, $index, 1); - - unset($this->items[$sec][$ptype]->policyMap[$hashKey]); - - $count = count($this->items[$sec][$ptype]->policy); - for ($i = $index; $i < $count; $i++) { - $this->items[$sec][$ptype]->policyMap[implode(self::DEFAULT_SEP, $this->items[$sec][$ptype]->policy[$i])] = $i; - } - - return true; - } - - /** - * Removes a policy rules from the model. - * - * @param string $sec - * @param string $ptype - * @param string[][] $rules - * - * @return bool - */ - public function removePolicies(string $sec, string $ptype, array $rules): bool - { - if (!isset($this->items[$sec][$ptype])) { - return false; - } - - foreach ($rules as $rule) { - $this->removePolicy($sec, $ptype, $rule); - } - - return true; - } - - /** - * Removes policy rules based on field filters from the model. - * - * @param string $sec - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * If more than one rule is removed, return the removed rule array, otherwise return false - * @return string[][]|false - */ - public function removeFilteredPolicy(string $sec, string $ptype, int $fieldIndex, string ...$fieldValues) - { - $tmp = []; - $effects = []; - $res = false; - - if (!isset($this->items[$sec][$ptype])) { - return $res; - } - - $this->items[$sec][$ptype]->policyMap = []; - - foreach ($this->items[$sec][$ptype]->policy as $index => $rule) { - $matched = true; - foreach ($fieldValues as $i => $fieldValue) { - if ('' != $fieldValue && $rule[$fieldIndex + intval($i)] != $fieldValue) { - $matched = false; - break; - } - } - - if ($matched) { - $effects[] = $rule; - } else { - $tmp[] = $rule; - $this->items[$sec][$ptype]->policyMap[implode(self::DEFAULT_SEP, $rule)] = count($tmp) - 1; - } - } - - if (count($tmp) != count($this->items[$sec][$ptype]->policy)) { - $this->items[$sec][$ptype]->policy = $tmp; - $res = true; - } - - return $res ? $effects : false; - } - - /** - * Gets all values for a field for all rules in a policy, duplicated values are removed. - * - * @param string $sec - * @param string $ptype - * @param int $fieldIndex - * - * @return string[] - */ - public function getValuesForFieldInPolicy(string $sec, string $ptype, int $fieldIndex): array - { - $values = []; - - if (!isset($this->items[$sec][$ptype])) { - return $values; - } - - foreach ($this->items[$sec][$ptype]->policy as $rule) { - $values[] = $rule[$fieldIndex]; - } - - arrayRemoveDuplicates($values); - - return $values; - } - - /** - * Gets all values for a field for all rules in a policy of all ptypes, duplicated values are removed. - * - * @param string $sec - * @param int $fieldIndex - * - * @return string[] - */ - public function getValuesForFieldInPolicyAllTypes(string $sec, int $fieldIndex): array - { - $values = []; - - foreach ($this->items[$sec] as $key => $ptype) { - $values = array_merge($values, $this->getValuesForFieldInPolicy($sec, $key, $fieldIndex)); - } - - arrayRemoveDuplicates($values); - - return $values; - } - - /** - * Gets all values for a field for all rules in a policy of all ptypes, duplicated values are removed. - * - * @param string $sec - * @param string $field - * - * @return array - * @throws CasbinException - */ - public function getValuesForFieldInPolicyAllTypesByName(string $sec, string $field): array - { - $values = []; - - foreach ($this->items[$sec] as $ptype => $rules) { - $index = $this->getFieldIndex($ptype, $field); - $v = $this->getValuesForFieldInPolicy($sec, $ptype, $index); - - $values = array_merge($values, $v); - } - - arrayRemoveDuplicates($values); - - return $values; - } - - /** - * Gets the index for a given ptype and field. - * - * @param string $ptype - * @param string $field - * - * @return int $fieldIndex - * @throws CasbinException - */ - public function getFieldIndex(string $ptype, string $field): int - { - $assertion = &$this->items['p'][$ptype]; - if (isset($assertion->fieldIndexMap[$field])) { - return $assertion->fieldIndexMap[$field]; - } - $pattern = $ptype . '_' . $field; - $index = -1; - foreach ($assertion->tokens as $i => $token) { - if ($token == $pattern) { - $index = $i; - break; - } - } - if ($index == -1) { - throw new CasbinException($field . ' index is not set, please use enforcer.SetFieldIndex() to set index'); - } - $assertion->fieldIndexMap[$field] = $index; - return $index; - } - - /** - * Sets the index for a given ptype and field. - * - * @param string $ptype - * @param string $field - * @param int $index - */ - public function setFieldIndex(string $ptype, string $field, int $index): void - { - $assertion = &$this->items['p'][$ptype]; - $assertion->fieldIndexMap[$field] = $index; - } - - /** - * Sets the current logger. - * - * @param Logger $logger - * - * @return void - */ - public function setLogger(Logger $logger): void - { - foreach ($this->items as $sec => $astMap) { - foreach ($astMap as $ast) { - $ast->setLogger($logger); - } - } - - $this->logger = $logger; - } - - /** - * Returns the current logger. - * - * @return Logger - */ - public function getLogger(): Logger - { - return $this->logger; - } - - /** - * Determine if the given Model option exists. - * - * @param string $offset - * - * @return bool - */ - public function offsetExists($offset): bool - { - return isset($this->items[$offset]); - } - - /** - * Get a Model option. - * - * @param string $offset - * - * @return array|null - */ - public function offsetGet($offset): ?array - { - return $this->items[$offset] ?? null; - } - - /** - * Set a Model option. - * - * @param string $offset - * @param array $value - */ - public function offsetSet($offset, $value): void - { - $this->items[$offset] = $value; - } - - /** - * Unset a Model option. - * - * @param string $offset - */ - public function offsetUnset($offset): void - { - unset($this->items[$offset]); - } -} diff --git a/plugins/Authorization/Engine/Persist/AdapterHelper.php b/plugins/Authorization/Engine/Persist/AdapterHelper.php deleted file mode 100644 index 87e5f1f..0000000 --- a/plugins/Authorization/Engine/Persist/AdapterHelper.php +++ /dev/null @@ -1,69 +0,0 @@ -loadPolicyArray($tokens, $model); - } - - /** - * Loads a policy rule to model. - * - * @param array $rule - * @param Model $model - */ - public function loadPolicyArray(array $rule, Model $model): void - { - $key = $rule[0]; - $sec = $key[0]; - - if (!isset($model[$sec][$key])) { - return; - } - - $assertions = $model[$sec]; - $assertion = $assertions[$key]; - if (!($assertion instanceof Assertion)) { - return; - } - - $rule = array_slice($rule, 1); - $assertion->policy[] = $rule; - $assertion->policyMap[implode(Policy::DEFAULT_SEP, $rule)] = count($assertion->policy) - 1; - - $assertions[$key] = $assertion; - $model[$sec] = $assertions; - } -} diff --git a/plugins/Authorization/Engine/Persist/Adapters/FileAdapter.php b/plugins/Authorization/Engine/Persist/Adapters/FileAdapter.php deleted file mode 100644 index f0e2fd9..0000000 --- a/plugins/Authorization/Engine/Persist/Adapters/FileAdapter.php +++ /dev/null @@ -1,235 +0,0 @@ -filePath = $filePath; - } - - /** - * Loads all policy rules from the storage. - * - * @param Model $model - * - * @throws CasbinException - */ - public function loadPolicy(Model $model): void - { - if (!file_exists($this->filePath)) { - throw new InvalidFilePathException('invalid file path, file path cannot be empty'); - } - - $this->loadPolicyFile($model); - } - - /** - * Saves all policy rules to the storage. - * - * @param Model $model - * - * @throws CasbinException - */ - public function savePolicy(Model $model): void - { - if ('' == $this->filePath) { - throw new InvalidFilePathException('invalid file path, file path cannot be empty'); - } - - $writeString = ''; - - if (isset($model['p'])) { - foreach ($model['p'] as $ptype => $ast) { - foreach ($ast->policy as $rule) { - $writeString .= $ptype . ', '; - $writeString .= arrayToCommaSeparatedStr($rule); - $writeString .= PHP_EOL; - } - } - } - - if (isset($model['g'])) { - foreach ($model['g'] as $ptype => $ast) { - foreach ($ast->policy as $rule) { - $writeString .= $ptype . ', '; - $writeString .= arrayToCommaSeparatedStr($rule); - $writeString .= PHP_EOL; - } - } - } - - $this->savePolicyFile(rtrim($writeString, PHP_EOL)); - } - - /** - * @param Model $model - * @throws InvalidFilePathException - */ - protected function loadPolicyFile(Model $model): void - { - $file = fopen($this->filePath, 'rb'); - - if (false === $file) { - throw new InvalidFilePathException(sprintf('Unable to access to the specified path "%s"', $this->filePath)); - } - - while ($line = fgets($file)) { - $this->loadPolicyLine(trim($line), $model); - } - fclose($file); - } - - /** - * @param string $text - */ - protected function savePolicyFile(string $text): void - { - file_put_contents($this->filePath, $text, LOCK_EX); - } - - /** - * Adds a policy rule to the storage. - * - * @param string $sec - * @param string $ptype - * @param string[] $rule - * - * @throws NotImplementedException - */ - public function addPolicy(string $sec, string $ptype, array $rule): void - { - throw new NotImplementedException('not implemented'); - } - - /** - * Adds a policy rule to the storage. - * - * @param string $sec - * @param string $ptype - * @param string[][] $rules - * - * @throws NotImplementedException - */ - public function addPolicies(string $sec, string $ptype, array $rules): void - { - throw new NotImplementedException('not implemented'); - } - - /** - * Removes a policy rule from the storage. - * - * @param string $sec - * @param string $ptype - * @param string[] $rule - * - * @throws NotImplementedException - */ - public function removePolicy(string $sec, string $ptype, array $rule): void - { - throw new NotImplementedException('not implemented'); - } - - /** - * Removes a policy rules from the storage. - * - * @param string $sec - * @param string $ptype - * @param string[][] $rules - * - * @throws NotImplementedException - */ - public function removePolicies(string $sec, string $ptype, array $rules): void - { - throw new NotImplementedException('not implemented'); - } - - /** - * Removes policy rules that match the filter from the storage. - * - * @param string $sec - * @param string $ptype - * @param int $fieldIndex - * @param string ...$fieldValues - * - * @throws NotImplementedException - */ - public function removeFilteredPolicy(string $sec, string $ptype, int $fieldIndex, string ...$fieldValues): void - { - throw new NotImplementedException('not implemented'); - } - - /** - * Updates a policy rule from storage. - * This is part of the Auto-Save feature. - * - * @param string $sec - * @param string $ptype - * @param string[] $oldRule - * @param string[] $newPolicy - */ - public function updatePolicy(string $sec, string $ptype, array $oldRule, array $newPolicy): void - { - throw new NotImplementedException('not implemented'); - } - - /** - * UpdatePolicies updates some policy rules to storage, like db, redis. - * - * @param string $sec - * @param string $ptype - * @param string[][] $oldRules - * @param string[][] $newRules - * @return void - */ - public function updatePolicies(string $sec, string $ptype, array $oldRules, array $newRules): void - { - throw new NotImplementedException('not implemented'); - } - - /** - * UpdateFilteredPolicies deletes old rules and adds new rules. - * - * @param string $sec - * @param string $ptype - * @param array $newPolicies - * @param integer $fieldIndex - * @param string ...$fieldValues - * @return array - */ - public function updateFilteredPolicies(string $sec, string $ptype, array $newPolicies, int $fieldIndex, string ...$fieldValues): array - { - throw new NotImplementedException('not implemented'); - } -} diff --git a/plugins/Authorization/Engine/Persist/Adapters/FileFilteredAdapter.php b/plugins/Authorization/Engine/Persist/Adapters/FileFilteredAdapter.php deleted file mode 100644 index 85b67fd..0000000 --- a/plugins/Authorization/Engine/Persist/Adapters/FileFilteredAdapter.php +++ /dev/null @@ -1,179 +0,0 @@ -filtered = true; - parent::__construct($filePath); - } - - /** - * Loads all policy rules from the storage. - * - * @param Model $model - * - * @throws CasbinException - */ - public function loadPolicy(Model $model): void - { - $this->filtered = false; - parent::loadPolicy($model); - } - - /** - * Loads only policy rules that match the filter. - * - * @param Model $model - * @param mixed $filter - * - * @throws CasbinException - */ - public function loadFilteredPolicy(Model $model, $filter): void - { - if (is_null($filter)) { - $this->loadPolicy($model); - - return; - } - - if (!file_exists($this->filePath)) { - throw new InvalidFilePathException('invalid file path, file path cannot be empty'); - } - - if (!$filter instanceof Filter) { - throw new InvalidFilterTypeException('invalid filter type'); - } - - $this->loadFilteredPolicyFile($model, $filter, [$this, 'loadPolicyLine']); - $this->filtered = true; - } - - /** - * Returns true if the loaded policy has been filtered. - * - * @return bool - */ - public function isFiltered(): bool - { - return $this->filtered; - } - - /** - * SavePolicy saves all policy rules to the storage. - * - * @param Model $model - * @throws CannotSaveFilteredPolicy|CasbinException - */ - public function savePolicy(Model $model): void - { - if ($this->filtered) { - throw new CannotSaveFilteredPolicy('cannot save a filtered policy'); - } - - parent::savePolicy($model); - } - - /** - * LoadFilteredPolicyFile function. - * - * @param Model $model - * @param Filter $filter - * @param callable $handler - * @throws InvalidFilePathException - */ - protected function loadFilteredPolicyFile(Model $model, Filter $filter, callable $handler): void - { - $file = fopen($this->filePath, 'rb'); - - if (false === $file) { - throw new InvalidFilePathException(sprintf('Unable to access to the specified path "%s"', $this->filePath)); - } - - while ($line = fgets($file)) { - $line = trim($line); - if (self::filterLine($line, $filter)) { - continue; - } - call_user_func($handler, $line, $model); - } - } - - /** - * FilterLine function. - * - * @param string $line - * @param Filter $filter - * - * @return bool - */ - protected static function filterLine(string $line, Filter $filter): bool - { - $p = explode(',', $line); - if (0 == strlen($p[0])) { - return true; - } - - $filterSlice = match (trim($p[0])) { - 'p' => $filter->p, - 'g' => $filter->g, - default => [] - }; - - return self::filterWords($p, $filterSlice); - } - - /** - * FilterWords function. - * - * @param array $line - * @param array $filter - * - * @return bool - */ - protected static function filterWords(array $line, array $filter): bool - { - if (count($line) < count($filter) + 1) { - return true; - } - $skipLine = false; - foreach ($filter as $i => $v) { - if (strlen($v) > 0 && \trim($v) != trim($line[$i + 1])) { - $skipLine = true; - - break; - } - } - - return $skipLine; - } -} diff --git a/plugins/Authorization/Engine/Persist/Adapters/Filter.php b/plugins/Authorization/Engine/Persist/Adapters/Filter.php deleted file mode 100644 index 1e1869f..0000000 --- a/plugins/Authorization/Engine/Persist/Adapters/Filter.php +++ /dev/null @@ -1,40 +0,0 @@ -p = $p; - $this->g = $g; - } -} diff --git a/plugins/Authorization/Engine/RBAC/ConditionalDomainManager.php b/plugins/Authorization/Engine/RBAC/ConditionalDomainManager.php deleted file mode 100644 index 9d31a7f..0000000 --- a/plugins/Authorization/Engine/RBAC/ConditionalDomainManager.php +++ /dev/null @@ -1,180 +0,0 @@ - - */ - protected array $rmMap = []; - - /** - * ConditionalDomainManager constructor. - * - * @param int $maxHierarchyLevel - */ - public function __construct(int $maxHierarchyLevel) - { - parent::__construct($maxHierarchyLevel); - } - - /** - * Gets the RoleManager for the given domain. - * - * @param string $domain - * @param bool $store - * @return ConditionalRoleManager - */ - public function &getRoleManager(string $domain, bool $store): ConditionalRoleManager - { - if (isset($this->rmMap[$domain])) { - return $this->rmMap[$domain]; - } - - $rm = new ConditionalRoleManager($this->maxHierarchyLevel, $this->matchingFunc); - if ($store) { - $this->rmMap[$domain] = $rm; - } - if (!is_null($this->domainMatchingFunc)) { - foreach ($this->rmMap as $domain2 => &$rm2) { - if ($domain !== $domain2 && $this->match($domain, $domain2)) { - $rm->copyFrom($rm2); - } - } - } - - return $rm; - } - - /** - * Adds the inheritance link between role: name1 and role: name2. - * aka role: name1 inherits role: name2. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domains - */ - public function addLink(string $name1, string $name2, string ...$domains): void - { - $domain = $this->getDomain(...$domains); - $rm = &$this->getRoleManager($domain, true); - $rm->addLink($name1, $name2); - - $this->rangeAffectedRoleManagers($domain, function (&$rm) use ($name1, $name2) { - $rm->addLink($name1, $name2); - }); - } - - /** - * Deletes the inheritance link between role: name1 and role: name2. - * aka role: name1 does not inherit role: name2 any more. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domains - */ - public function deleteLink(string $name1, string $name2, string ...$domains): void - { - $domain = $this->getDomain(...$domains); - $rm = &$this->getRoleManager($domain, true); - $rm->deleteLink($name1, $name2); - - $this->rangeAffectedRoleManagers($domain, function (&$rm) use ($name1, $name2) { - $rm->deleteLink($name1, $name2); - }); - } - - /** - * Determines whether role: name1 inherits role: name2. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domains - * - * @return bool - */ - public function hasLink(string $name1, string $name2, string ...$domains): bool - { - $domain = $this->getDomain(...$domains); - $rm = &$this->getRoleManager($domain, true); - return $rm->hasLink($name1, $name2, ...$domains); - } - - /** - * AddLinkConditionFunc Add condition function fn for Link userName->roleName, - * when fn returns true, Link is valid, otherwise invalid - * - * @param string $userName - * @param string $roleName - * @param Closure $linkConditionFunc - */ - public function addLinkConditionFunc(string $userName, string $roleName, Closure $linkConditionFunc): void - { - foreach ($this->rmMap as $_ => &$rm) { - $rm->addLinkConditionFunc($userName, $roleName, $linkConditionFunc); - } - } - - /** - * AddDomainLinkConditionFunc Add condition function fn for Link userName-> {roleName, domain}, - * when fn returns true, Link is valid, otherwise invalid - * - * @param string $userName - * @param string $roleName - * @param string $domain - * @param Closure $linkConditionFunc - */ - public function addDomainLinkConditionFunc(string $userName, string $roleName, string $domain, Closure $linkConditionFunc): void - { - foreach ($this->rmMap as $_ => &$rm) { - $rm->addDomainLinkConditionFunc($userName, $roleName, $domain, $linkConditionFunc); - } - } - - /** - * SetLinkConditionFuncParams Sets the parameters of the condition function fn for Link userName->roleName - * - * @param string $userName - * @param string $roleName - * @param string ...$params - */ - public function setLinkConditionFuncParams(string $userName, string $roleName, string ...$params): void - { - foreach ($this->rmMap as $_ => &$rm) { - $rm->setLinkConditionFuncParams($userName, $roleName, ...$params); - } - } - - /** - * SetDomainLinkConditionFuncParams Sets the parameters of the condition function fn - * for Link userName->{roleName, domain} - * - * @param string $userName - * @param string $roleName - * @param string $domain - * @param string ...$params - */ - public function setDomainLinkConditionFuncParams(string $userName, string $roleName, string $domain, string ...$params): void - { - foreach ($this->rmMap as $_ => &$rm) { - $rm->setDomainLinkConditionFuncParams($userName, $roleName, $domain, ...$params); - } - } -} diff --git a/plugins/Authorization/Engine/RBAC/ConditionalRoleManager.php b/plugins/Authorization/Engine/RBAC/ConditionalRoleManager.php deleted file mode 100644 index 7fc454d..0000000 --- a/plugins/Authorization/Engine/RBAC/ConditionalRoleManager.php +++ /dev/null @@ -1,295 +0,0 @@ -clear(); - $this->maxHierarchyLevel = $maxHierarchyLevel; - $this->setLogger(new DefaultLogger()); - $this->matchingFunc = $matchingFunc; - } - - /** - * Determines whether role: name1 inherits role: name2. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domain - * - * @return bool - */ - public function hasLink(string $name1, string $name2, string ...$domain): bool - { - if ($name1 == $name2 || (!is_null($this->matchingFunc) && $this->match($name1, $name2))) { - return true; - } - - $userGet = &$this->getRole($name1); - $roleGet = &$this->getRole($name2); - $user = &$userGet[0]; - $role = &$roleGet[0]; - $userCreated = $userGet[1]; - $roleCreated = $roleGet[1]; - - try { - return $this->hasLinkHelper($role->name, [$user->name => $user], $this->maxHierarchyLevel, ...$domain); - } finally { - if ($userCreated) { - $this->removeRole($user->name); - } - - if ($roleCreated) { - $this->removeRole($role->name); - } - } - } - - /** - * @param string $targetName - * @param array $roles - * @param int $level - * @return bool - */ - protected function hasLinkHelper(string $targetName, array $roles, int $level, string ...$domain): bool - { - if ($level < 0 || count($roles) == 0) { - return false; - } - - $nextRoles = []; - foreach ($roles as $name => $role) { - if ($targetName === $role->name || (!is_null($this->matchingFunc) && $this->match($role->name, $targetName))) { - return true; - } - - try { - $role->rangeRoles(function ($name, $nextRole) use (&$role, $domain, &$nextRoles) { - if (!$this->getNextRoles($role, $nextRole, $domain, $nextRoles)) { - throw new CasbinException('failed to get next roles'); - }; - }); - } catch (CasbinException) { - continue; - } - } - - return $this->hasLinkHelper($targetName, $nextRoles, $level - 1); - } - - /** - * @param Role $currentRole - * @param Role $nextRole - * @param array $domain - * @param array $nextRoles - * - * @return bool - */ - protected function getNextRoles(Role $currentRole, Role $nextRole, array $domain, array &$nextRoles): bool - { - $passLinkConditionFunc = true; - try { - if (count($domain) === 0) { - $linkConditionFunc = $this->getLinkConditionFunc($currentRole->name, $nextRole->name); - if (!is_null($linkConditionFunc)) { - $params = $this->getLinkConditionFuncParams($currentRole->name, $nextRole->name); - $passLinkConditionFunc = $linkConditionFunc(...$params); - } - } else { - $linkConditionFunc = $this->getDomainLinkConditionFunc($currentRole->name, $nextRole->name, $domain[0]); - if (!is_null($linkConditionFunc)) { - $params = $this->getDomainLinkConditionFuncParams($currentRole->name, $nextRole->name, $domain[0]); - $passLinkConditionFunc = $linkConditionFunc(...$params); - } - } - } catch (Exception $e) { - $this->logger->logError($e, 'hasLinkHelper LinkCondition Error'); - return false; - } - - if ($passLinkConditionFunc) { - $nextRoles[$nextRole->name] = $nextRole; - } - - return true; - } - - /** - * @param string $userName - * @param string $roleName - * - * @return Closure|null - */ - private function getLinkConditionFunc(string $userName, string $roleName): ?Closure - { - return $this->getDomainLinkConditionFunc($userName, $roleName, RoleManager::DEFAULT_DOMAIN); - } - - /** - * @param string $userName - * @param string $roleName - * @param string $domain - * - * @return Closure|null - */ - private function getDomainLinkConditionFunc(string $userName, string $roleName, string $domain): ?Closure - { - $userGet = &$this->getRole($userName); - $roleGet = &$this->getRole($roleName); - $user = &$userGet[0]; - $role = &$roleGet[0]; - $userCreated = $userGet[1]; - $roleCreated = $roleGet[1]; - - if ($userCreated) { - $this->removeRole($user->name); - return null; - } - - if ($roleCreated) { - $this->removeRole($role->name); - return null; - } - - return $user->getLinkConditionFunc($role, $domain); - } - - /** - * @param string $userName - * @param string $roleName - * - * @return array|null - */ - private function getLinkConditionFuncParams(string $userName, string $roleName): ?array - { - return $this->getDomainLinkConditionFuncParams($userName, $roleName, RoleManager::DEFAULT_DOMAIN); - } - - /** - * @param string $userName - * @param string $roleName - * @param string $domain - * - * @return array|null - */ - private function getDomainLinkConditionFuncParams(string $userName, string $roleName, string $domain): ?array - { - $userGet = &$this->getRole($userName); - $roleGet = &$this->getRole($roleName); - $user = &$userGet[0]; - $role = &$roleGet[0]; - $userCreated = $userGet[1]; - $roleCreated = $roleGet[1]; - - if ($userCreated) { - $this->removeRole($user->name); - return null; - } - - if ($roleCreated) { - $this->removeRole($role->name); - return null; - } - - return $user->getLinkConditionFuncParams($role, $domain); - } - - /** - * AddLinkConditionFunc Add condition function fn for Link userName->roleName, - * when fn returns true, Link is valid, otherwise invalid - * - * @param string $userName - * @param string $roleName - * @param Closure $linkConditionFunc - */ - public function addLinkConditionFunc(string $userName, string $roleName, Closure $linkConditionFunc): void - { - $this->addDomainLinkConditionFunc($userName, $roleName, RoleManager::DEFAULT_DOMAIN, $linkConditionFunc); - } - - /** - * AddDomainLinkConditionFunc Add condition function fn for Link userName-> {roleName, domain}, - * when fn returns true, Link is valid, otherwise invalid - * - * @param string $userName - * @param string $roleName - * @param string $domain - * @param Closure $linkConditionFunc - */ - public function addDomainLinkConditionFunc(string $userName, string $roleName, string $domain, Closure $linkConditionFunc): void - { - $userGet = &$this->getRole($userName); - $roleGet = &$this->getRole($roleName); - $user = &$userGet[0]; - $role = &$roleGet[0]; - - $user->addLinkConditionFunc($role, $domain, $linkConditionFunc); - } - - /** - * SetLinkConditionFuncParams Sets the parameters of the condition function fn for Link userName->roleName - * - * @param string $userName - * @param string $roleName - * @param string ...$params - */ - public function setLinkConditionFuncParams(string $userName, string $roleName, string ...$params): void - { - $this->setDomainLinkConditionFuncParams($userName, $roleName, RoleManager::DEFAULT_DOMAIN, ...$params); - } - - /** - * SetDomainLinkConditionFuncParams Sets the parameters of the condition function fn - * for Link userName->{roleName, domain} - * - * @param string $userName - * @param string $roleName - * @param string $domain - * @param string ...$params - */ - public function setDomainLinkConditionFuncParams(string $userName, string $roleName, string $domain, string ...$params): void - { - $userGet = &$this->getRole($userName); - $roleGet = &$this->getRole($roleName); - $user = &$userGet[0]; - $role = &$roleGet[0]; - - $user->setLinkConditionFuncParams($role, $domain, ...$params); - } - - /** - * @param ConditionalRoleManager $roleManager - */ - public function copyFrom(ConditionalRoleManager &$roleManager): void - { - $this->rangeLinks($roleManager->allRoles, function ($name1, $name2, $domain) { - $this->addLink($name1, $name2, $domain); - }); - } -} diff --git a/plugins/Authorization/Engine/RBAC/DomainManager.php b/plugins/Authorization/Engine/RBAC/DomainManager.php deleted file mode 100644 index 7f92dfe..0000000 --- a/plugins/Authorization/Engine/RBAC/DomainManager.php +++ /dev/null @@ -1,33 +0,0 @@ - - */ - protected array $rmMap = []; - - /** - * DomainManager constructor. - * - * @param int $maxHierarchyLevel - */ - public function __construct(int $maxHierarchyLevel) - { - parent::__construct($maxHierarchyLevel); - } -} diff --git a/plugins/Authorization/Engine/RBAC/Role.php b/plugins/Authorization/Engine/RBAC/Role.php deleted file mode 100644 index bdb6974..0000000 --- a/plugins/Authorization/Engine/RBAC/Role.php +++ /dev/null @@ -1,312 +0,0 @@ - - */ - public array $roles = []; - - /** - * @var array - */ - private array $users = []; - - /** - * @var array - */ - private array $matched = []; - - /** - * @var array - */ - private array $matchedBy = []; - - /** - * @var array - */ - private array $linkConditionFuncMap = []; - - /** - * @var array - */ - private array $linkConditionFuncParamsMap = []; - - /** - * Role constructor. - * - * @param string $name - */ - public function __construct(string $name) - { - $this->name = $name; - } - - /** - * Add a role to this role's list of roles. - * - * @param self $role - */ - public function addRole(self $role): void - { - $this->roles[$role->name] = $role; - $role->addUser($this); - } - - /** - * Remove a role from this role's list of roles. - * - * @param self $role - */ - public function removeRole(self $role): void - { - unset($this->roles[$role->name]); - $role->removeUser($this); - } - - /** - * Add a user to this role's list of users. - * - * @param self $user - */ - public function addUser(self $user): void - { - $this->users[$user->name] = $user; - } - - /** - * Remove a user from this role's list of users. - * - * @param self $user - */ - public function removeUser(self $user): void - { - unset($this->users[$user->name]); - } - - /** - * Add a matching role to this role. - * - * @param self $role - */ - public function addMatch(self $role): void - { - $this->matched[$role->name] = $role; - $role->matchedBy[$this->name] = $this; - } - - /** - * Remove a matching role from this role. - * - * @param self $role - */ - public function removeMatch(self $role): void - { - unset($this->matched[$role->name]); - unset($role->matchedBy[$this->name]); - } - - /** - * Remove all matches for this role. - */ - public function removeMatches(): void - { - foreach ($this->matched as &$role) { - $this->removeMatch($role); - } - foreach ($this->matchedBy as &$role) { - $role->removeMatch($this); - } - } - - /** - * Applies a callback to all roles that this role matches. - * - * @param Closure $fn - */ - public function rangeRoles(Closure $fn): void - { - array_walk($this->roles, function (&$role, $name) use ($fn) { - $fn($name, $role); - }); - - array_walk($this->roles, function ($role) use ($fn) { - array_walk($role->matched, function (&$value, $key) use ($fn) { - $fn($key, $value); - }); - }); - - array_walk($this->matchedBy, function ($role) use ($fn) { - array_walk($role->roles, function (&$value, $key) use ($fn) { - $fn($key, $value); - }); - }); - } - - /** - * Applies a callback to all users that this role matches. - * - * @param Closure $fn - */ - public function rangeUsers(Closure $fn): void - { - array_walk($this->users, function (&$user, $name) use ($fn) { - $fn($name, $user); - }); - - array_walk($this->users, function ($user) use ($fn) { - array_walk($user->matched, function (&$value, $key) use ($fn) { - $fn($key, $value); - }); - }); - - array_walk($this->matchedBy, function ($user) use ($fn) { - array_walk($user->users, function (&$value, $key) use ($fn) { - $fn($key, $value); - }); - }); - } - - /** - * Converts the role to a string representation. - * The string contains the role's name and the names of roles it contains or matches. - * - * @return string The string representation of the role. - */ - public function toString(): string - { - $len = count($this->roles); - - if (0 == $len) { - return ''; - } - - $names = implode(', ', $this->getRoles()); - - if (1 == $len) { - return $this->name . ' < ' . $names; - } else { - return $this->name . ' < (' . $names . ')'; - } - } - - /** - * Returns a list of all roles that this role matches. - * - * @return string[] - */ - public function getRoles(): array - { - $names = []; - $this->rangeRoles(function ($name, $role) use (&$names) { - $names[] = $name; - }); - return array_uniqueness($names); - } - - /** - * Returns a list of all users that this role matches. - * - * @return string[] - */ - public function getUsers(): array - { - $names = []; - $this->rangeUsers(function ($name, $user) use (&$names) { - $names[] = $name; - }); - return $names; - } - - /** - * Adds a link condition function to this role. - * A link condition function is used to define specific conditions for linking roles. - * - * @param Role $role The role to which the link condition applies. - * @param string $domain The domain for the link condition. - * @param Closure $fn The link condition function. - */ - public function addLinkConditionFunc(Role $role, string $domain, Closure $fn): void - { - $this->linkConditionFuncMap[$this->getLinkConditionFuncKey($role, $domain)] = $fn; - } - - /** - * Gets the link condition function for a role, if it exists. - * - * @param Role $role The role to which the link condition applies. - * @param string $domain The domain for the link condition. - * - * @return Closure|null The link condition function or null if none exists. - */ - public function getLinkConditionFunc(Role $role, string $domain): ?Closure - { - $key = $this->getLinkConditionFuncKey($role, $domain); - return $this->linkConditionFuncMap[$key] ?? null; - } - - /** - * Sets parameters for a link condition function. - * - * @param Role $role The role to which the link condition applies. - * @param string $domain The domain for the link condition. - * @param array $params The parameters to set for the link condition. - */ - public function setLinkConditionFuncParams(Role $role, string $domain, ...$params): void - { - $this->linkConditionFuncParamsMap[$this->getLinkConditionFuncKey($role, $domain)] = $params; - } - - /** - * Gets the parameters for a link condition function. - * - * @param Role $role The role to which the link condition applies. - * @param string $domain The domain for the link condition. - * - * @return array|null The parameters for the link condition or null if none exist. - */ - public function getLinkConditionFuncParams(Role $role, string $domain): ?array - { - $key = $this->getLinkConditionFuncKey($role, $domain); - return $this->linkConditionFuncParamsMap[$key] ?? null; - } - - /** - * Generates a key for the link condition function map. - * The key is a combination of the role name and the domain. - * - * @param Role $role The role to which the link condition applies. - * @param string $domain The domain for the link condition. - * - * @return string The generated key. - */ - private function getLinkConditionFuncKey(Role $role, string $domain): string - { - return $role->name . '_' . $domain; - } -} diff --git a/plugins/Authorization/Engine/RBAC/RoleManager.php b/plugins/Authorization/Engine/RBAC/RoleManager.php deleted file mode 100644 index 30bfd32..0000000 --- a/plugins/Authorization/Engine/RBAC/RoleManager.php +++ /dev/null @@ -1,46 +0,0 @@ -clear(); - $this->maxHierarchyLevel = $maxHierarchyLevel; - $this->matchingFunc = $matchingFunc; - $this->setLogger(new DefaultLogger()); - } - - /** - * @param RoleManager $roleManager - */ - public function copyFrom(RoleManager &$roleManager): void - { - $this->rangeLinks($roleManager->allRoles, function ($name1, $name2, $domain) { - $this->addLink($name1, $name2, $domain); - }); - } -} diff --git a/plugins/Authorization/Engine/RBAC/Supports/BaseManager.php b/plugins/Authorization/Engine/RBAC/Supports/BaseManager.php deleted file mode 100644 index d03448d..0000000 --- a/plugins/Authorization/Engine/RBAC/Supports/BaseManager.php +++ /dev/null @@ -1,141 +0,0 @@ -logger = $logger; - } - - - - /** - * Sets the matching function for roles or conditions. - * - * The matching function is invoked to evaluate whether certain conditions (such as role properties) are met. - * For example, you can define custom matching logic for assigning roles or permissions. - * - * Example usage: - * $roleManager->setMatchingFunc(function($role) { - * return $role->hasPermission('view_dashboard'); - * }); - * - * @param Closure $matchingFunc The function to match roles or conditions. - * - * @return void - */ - public function setMatchingFunc(Closure $matchingFunc): void - { - $this->matchingFunc = $matchingFunc; - } - - /** - * Sets the domain-specific matching function. - * - * This function adds granularity to role or permission assignments by matching conditions based on the domain. - * For example, a user might have a role in one domain but not another, and this method helps evaluate that. - * - * Example usage: - * $roleManager->setDomainMatchingFunc(function($role, $domain) { - * return $domain === 'admin' && $role->hasPermission('view_dashboard'); - * }); - * - * @param Closure $domainMatchingFunc The function to match roles based on domain-specific conditions. - * - * @return void - */ - public function setDomainMatchingFunc(Closure $domainMatchingFunc): void - { - $this->domainMatchingFunc = $domainMatchingFunc; - } - - /** - * Gets the current matching function. - * - * This method returns the matching function, which can be used to evaluate roles or conditions. - * - * Example usage: - * $matchingFunc = $roleManager->getMatchingFunc(); - * - * @return Closure|null The current matching function, or null if not set. - */ - public function getMatchingFunc(): ?Closure - { - return $this->matchingFunc; - } - - /** - * Gets the current domain-specific matching function. - * - * This method returns the domain-specific matching function, used for evaluating conditions within a domain. - * - * Example usage: - * $domainMatchingFunc = $roleManager->getDomainMatchingFunc(); - * - * @return Closure|null The current domain-specific matching function, or null if not set. - */ - public function getDomainMatchingFunc(): ?Closure - { - return $this->domainMatchingFunc; - } - - /** - * Gets the maximum hierarchy level. - * - * This method returns the maximum allowed level of nested roles, ensuring role assignments do not exceed - * the defined maximum hierarchy level. - * - * Example usage: - * $maxLevel = $roleManager->getMaxHierarchyLevel(); - * - * @return int The maximum hierarchy level for role assignments. - */ - public function getMaxHierarchyLevel(): int - { - return $this->maxHierarchyLevel; - } -} diff --git a/plugins/Authorization/Engine/RBAC/Supports/DomainManager.php b/plugins/Authorization/Engine/RBAC/Supports/DomainManager.php deleted file mode 100644 index 5aca6fc..0000000 --- a/plugins/Authorization/Engine/RBAC/Supports/DomainManager.php +++ /dev/null @@ -1,313 +0,0 @@ -matchingFunc = $fn; - foreach ($this->rmMap as $_ => &$rm) { - $rm->addMatchingFunc($name, $fn); - } - } - - /** - * Support use domain pattern in g. - * - * @param string $name - * @param Closure $fn - */ - public function addDomainMatchingFunc(string $name, Closure $fn): void - { - $this->domainMatchingFunc = $fn; - foreach ($this->rmMap as $_ => &$rm) { - $rm->addDomainMatchingFunc($name, $fn); - } - $this->rebuild(); - } - - /** - * Clears the map of RoleManagers. - */ - public function rebuild(): void - { - $rmMap = $this->rmMap; - $this->clear(); - foreach ($rmMap as $domain => &$rm) { - $rm->rangeSelfLinks(function ($name1, $name2, $_) use ($domain) { - $this->addLink($name1, $name2, $domain); - }); - } - } - - /** - * Clears all stored data and resets the role manager to the initial state. - */ - public function clear(): void - { - $this->rmMap = []; - } - - /** - * Gets the domain from the given arguments. - * - * @param string|null $domain - * @return string - */ - public function getDomain(?string $domain = null): string - { - if (is_null($domain)) { - return RoleManagerInterface::DEFAULT_DOMAIN; - } - return $domain; - } - - /** - * Determines whether a string matches a pattern. - * - * @param string $str - * @param string $pattern - * @return bool - */ - public function match(string $str, string $pattern): bool - { - if ($str === $pattern) { - return true; - } - - if (!is_null($this->domainMatchingFunc)) { - return call_user_func($this->domainMatchingFunc, $str, $pattern) === true; - } else { - return false; - } - } - - /** - * Applies a callback to all RoleManagers that match the given domain. - * - * @param string $domain - * @param Closure $fn - */ - public function rangeAffectedRoleManagers(string $domain, Closure $fn): void - { - if (!is_null($this->domainMatchingFunc)) { - foreach ($this->rmMap as $domain2 => &$rm) { - if ($domain !== $domain2 && $this->match($domain2, $domain)) { - $fn($rm); - } - } - } - } - - - /** - * Gets the RoleManager for the given domain. - * - * @param string $domain - * @param bool $store - * @return DefaultRoleManager - */ - public function &getRoleManager(string $domain, bool $store): DefaultRoleManager - { - if (isset($this->rmMap[$domain])) { - return $this->rmMap[$domain]; - } - - $rm = new DefaultRoleManager($this->maxHierarchyLevel, $this->matchingFunc); - if ($store) { - $this->rmMap[$domain] = $rm; - } - if (!is_null($this->domainMatchingFunc)) { - foreach ($this->rmMap as $domain2 => &$rm2) { - if ($domain !== $domain2 && $this->match($domain, $domain2)) { - $rm->copyFrom($rm2); - } - } - } - - return $rm; - } - - /** - * Adds the inheritance link between role: name1 and role: name2. - * aka role: name1 inherits role: name2. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domains - */ - public function addLink(string $name1, string $name2, string ...$domains): void - { - $domain = $this->getDomain(...$domains); - $rm = &$this->getRoleManager($domain, true); - $rm->addLink($name1, $name2); - - $this->rangeAffectedRoleManagers($domain, function (&$rm) use ($name1, $name2) { - $rm->addLink($name1, $name2); - }); - } - - /** - * Deletes the inheritance link between role: name1 and role: name2. - * aka role: name1 does not inherit role: name2 any more. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domains - */ - public function deleteLink(string $name1, string $name2, string ...$domains): void - { - $domain = $this->getDomain(...$domains); - $rm = &$this->getRoleManager($domain, true); - $rm->deleteLink($name1, $name2); - - $this->rangeAffectedRoleManagers($domain, function (&$rm) use ($name1, $name2) { - $rm->deleteLink($name1, $name2); - }); - } - - /** - * Determines whether role: name1 inherits role: name2. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domains - * - * @return bool - */ - public function hasLink(string $name1, string $name2, string ...$domains): bool - { - $domain = $this->getDomain(...$domains); - $rm = &$this->getRoleManager($domain, false); - return $rm->hasLink($name1, $name2, ...$domains); - } - - /** - * Gets the roles that a subject inherits. - * domain is a prefix to the roles. - * - * @param string $name - * @param string ...$domains - * - * @return string[] - */ - public function getRoles(string $name, string ...$domains): array - { - $domain = $this->getDomain(...$domains); - $rm = &$this->getRoleManager($domain, false); - return $rm->getRoles($name, ...$domains); - } - - /** - * Gets the users that inherits a subject. - * domain is an unreferenced parameter here, may be used in other implementations. - * - * @param string $name - * @param string ...$domains - * - * @return string[] - */ - public function getUsers(string $name, string ...$domains): array - { - $domain = $this->getDomain(...$domains); - $rm = &$this->getRoleManager($domain, false); - return $rm->getUsers($name, ...$domains); - } - - - /** - * Converts the roles to a string array. - * - * @return string[] - */ - public function toString(): array - { - $roles = []; - - foreach ($this->rmMap as $domain => &$rm) { - $domainRoles = $rm->toString(); - $roles[] = sprintf('%s: %s', $domain, implode(', ', $domainRoles)); - } - - return $roles; - } - - /** - * Prints all the roles to log. - */ - public function printRoles(): void - { - if (!$this->logger->isEnabled()) { - return; - } - - $roles = $this->toString(); - $this->logger->logRole($roles); - } - - /** - * Gets the domains that a subject inherits. - * - * @param string $name - * - * @return string[] - */ - public function getDomains(string $name): array - { - $domains = []; - foreach ($this->rmMap as $domain => &$rm) { - $roleGet = $rm->getRole($name); - $role = $roleGet[0]; - $roleCreated = $roleGet[1]; - - if (count($role->getUsers()) > 0 || count($role->getRoles()) > 0) { - $domains[] = $domain; - } - - if ($roleCreated) { - $this->removeRole($role->name); - } - } - return $domains; - } - - /** - * Gets all the domains. - * - * @return string[] - */ - public function getAllDomains(): array - { - $domains = []; - foreach ($this->rmMap as $domain => $_) { - $domains[] = $domain; - } - return $domains; - } -} diff --git a/plugins/Authorization/Engine/RBAC/Supports/RoleManager.php b/plugins/Authorization/Engine/RBAC/Supports/RoleManager.php deleted file mode 100644 index 65accef..0000000 --- a/plugins/Authorization/Engine/RBAC/Supports/RoleManager.php +++ /dev/null @@ -1,385 +0,0 @@ - - */ - protected array $allRoles = []; - - /** - * Clears the map of Roles. - * - * @return void - */ - protected function rebuild(): void - { - $roles = $this->allRoles; - $this->clear(); - $this->rangeLinks($roles, function (string $name1, string $name2, string $domain) { - $this->addLink($name1, $name2, $domain); - }); - } - - /** - * Determines whether a string matches a pattern. - * - * @param string $str - * @param string $pattern - * - * @return bool - */ - public function match(string $str, string $pattern): bool - { - if ($str === $pattern) { - return true; - } - - if (!is_null($this->matchingFunc)) { - return call_user_func($this->matchingFunc, $str, $pattern) === true; - } - return false; - } - - /** - * Applies a callback to all roles that match the given name or pattern. - * - * @param string $name - * @param bool $isPattern - * @param Closure $fn - * - * @return void - */ - protected function rangeMatchRoles(string $name, bool $isPattern, Closure $fn): void - { - foreach ($this->allRoles as $name2 => &$role) { - if ($isPattern && $name !== $name2 && $this->match($name2, $name)) { - $fn($role); - } else if (!$isPattern && $name !== $name2 && $this->match($name, $name2)) { - $fn($role); - } - } - } - - /** - * Gets the role by given name. - * - * @param string $name - * - * @return array - */ - public function &getRole(string $name): array - { - if (isset($this->allRoles[$name])) { - $res = [&$this->allRoles[$name], false]; - return $res; - } - - $role = new Role($name); - $this->allRoles[$name] = $role; - - if (!is_null($this->matchingFunc)) { - $this->rangeMatchRoles($name, false, function (Role &$r) use (&$role) { - $r->addMatch($role); - }); - - $this->rangeMatchRoles($name, true, function (Role &$r) use (&$role) { - $role->addMatch($r); - }); - } - - $res = [&$this->allRoles[$name], true]; - return $res; - } - - /** - * @param array $map - * @param string $name - * - * @return mixed - */ - protected function loadAndDelete(array &$map, string $name): mixed - { - if (isset($map[$name])) { - $value = $map[$name]; - unset($map[$name]); - } - return $value ?? null; - } - - /** - * Removes the role with the given name. - * - * @param string $name - */ - protected function removeRole(string $name): void - { - $role = $this->loadAndDelete($this->allRoles, $name); - if (!is_null($role)) { - $role->removeMatches(); - } - } - - /** - * Support use pattern in g. - * - * @param string $name - * @param Closure $fn - */ - public function addMatchingFunc(string $name, Closure $fn): void - { - $this->matchingFunc = $fn; - $this->rebuild(); - } - - /** - * Support use domain pattern in g. - * - * @param string $name - * @param Closure $fn - */ - public function addDomainMatchingFunc(string $name, Closure $fn): void - { - $this->domainMatchingFunc = $fn; - } - - /** - * Clears all stored data and resets the role manager to the initial state. - */ - public function clear(): void - { - $this->allRoles = []; - } - - /** - * Adds the inheritance link between role: name1 and role: name2. - * aka role: name1 inherits role: name2. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domain - */ - public function addLink(string $name1, string $name2, string ...$domain): void - { - $userGet = &$this->getRole($name1); - $roleGet = &$this->getRole($name2); - $userGet[0]->addRole($roleGet[0]); - } - - /** - * Deletes the inheritance link between role: name1 and role: name2. - * aka role: name1 does not inherit role: name2 any more. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domain - */ - public function deleteLink(string $name1, string $name2, string ...$domain): void - { - $userGet = &$this->getRole($name1); - $roleGet = &$this->getRole($name2); - $userGet[0]->removeRole($roleGet[0]); - } - - /** - * Determines whether role: name1 inherits role: name2. - * domain is a prefix to the roles. - * - * @param string $name1 - * @param string $name2 - * @param string ...$domain - * - * @return bool - */ - public function hasLink(string $name1, string $name2, string ...$domain): bool - { - if ($name1 == $name2 || (!is_null($this->matchingFunc) && $this->match($name1, $name2))) { - return true; - } - - $userGet = &$this->getRole($name1); - $roleGet = &$this->getRole($name2); - $user = &$userGet[0]; - $role = &$roleGet[0]; - $userCreated = $userGet[1]; - $roleCreated = $roleGet[1]; - - try { - return $this->hasLinkHelper($role->name, [$user->name => $user], $this->maxHierarchyLevel); - } finally { - if ($userCreated) { - $this->removeRole($user->name); - } - - if ($roleCreated) { - $this->removeRole($role->name); - } - } - } - - /** - * @param string $targetName - * @param array $roles - * @param int $level - * @return bool - */ - protected function hasLinkHelper(string $targetName, array $roles, int $level): bool - { - if ($level < 0 || count($roles) == 0) { - return false; - } - - $nextRoles = []; - foreach ($roles as $name => $role) { - if ($targetName === $role->name || (!is_null($this->matchingFunc) && $this->match($role->name, $targetName))) { - return true; - } - - $role->rangeRoles(function ($name, &$role) use (&$nextRoles) { - $nextRoles[$name] = $role; - }); - } - - return $this->hasLinkHelper($targetName, $nextRoles, $level - 1); - } - - /** - * Gets the roles that a subject inherits. - * domain is a prefix to the roles. - * - * @param string $name - * @param string ...$domain - * - * @return string[] - */ - public function getRoles(string $name, string ...$domain): array - { - $userGet = &$this->getRole($name); - $user = &$userGet[0]; - $userCreated = $userGet[1]; - try { - return $user->getRoles(); - } finally { - if ($userCreated) { - $this->removeRole($user->name); - } - } - } - - /** - * Gets the users that inherits a subject. - * domain is an unreferenced parameter here, may be used in other implementations. - * - * @param string $name - * @param string ...$domain - * - * @return string[] - */ - public function getUsers(string $name, string ...$domain): array - { - $roleGet = &$this->getRole($name); - $role = &$roleGet[0]; - $roleCreated = $roleGet[1]; - try { - return $role->getUsers(); - } finally { - if ($roleCreated) { - $this->removeRole($role->name); - } - } - } - - /** - * Converts the roles to a string array. - * - * @return array - */ - public function toString(): array - { - $roles = []; - - $roles = array_map(function (&$role) { - return $role->toString(); - }, $this->allRoles); - - return $roles; - } - - /** - * Prints all the roles to log. - */ - public function printRoles(): void - { - if (!$this->logger->isEnabled()) { - return; - } - $roles = $this->toString(); - $this->logger->logRole($roles); - } - - /** - * Gets the domains that a subject inherits. - * - * @param string $name - * - * @return string[] - */ - public function getDomains(string $name): array - { - return [RoleManagerInterface::DEFAULT_DOMAIN]; - } - - /** - * Gets all the domains. - * - * @return string[] - */ - public function getAllDomains(): array - { - return [RoleManagerInterface::DEFAULT_DOMAIN]; - } - - /** - * Applies a callback to all the links between users and roles. - * - * @param array &$users - * @param Closure $fn - */ - public function rangeLinks(array &$users, Closure $fn): void - { - foreach ($users as &$user) { - foreach ($user->roles as $roleName => $_) { - $fn($user->name, $roleName, RoleManagerInterface::DEFAULT_DOMAIN); - } - } - } - - /** - * Applies a callback to all the links between users and roles in itself. - * - * @param Closure $fn - */ - public function rangeSelfLinks(Closure $fn): void - { - $this->rangeLinks($this->allRoles, $fn); - } -} diff --git a/plugins/Authorization/Engine/Util/BuiltinOperations.php b/plugins/Authorization/Engine/Util/BuiltinOperations.php deleted file mode 100644 index cc88384..0000000 --- a/plugins/Authorization/Engine/Util/BuiltinOperations.php +++ /dev/null @@ -1,527 +0,0 @@ - $i) { - if (substr($key1, 0, $i) == substr($key2, 0, $i)) { - return substr($key1, $i); - } - } - return ''; - } - - /** - * KeyGetFunc is the wrapper for KeyGet - * - * @param mixed ...$args - * @return string - */ - public static function keyGetFunc(...$args) - { - $name1 = $args[0]; - $name2 = $args[1]; - - return self::keyGet($name1, $name2); - } - - /** - * Determines whether key1 matches the pattern of key2 (similar to RESTful path), key2 can contain a *. - * For example, "/foo/bar" matches "/foo/*", "/resource1" matches "/:resource". - * - * @param string $key1 - * @param string $key2 - * - * @return bool - */ - public static function keyMatch2(string $key1, string $key2): bool - { - if ('*' === $key2) { - $key2 = '.*'; - } - $key2 = str_replace(['/*'], ['/.*'], $key2); - - $pattern = '/:[^\/]+/'; - - $key2 = preg_replace_callback( - $pattern, - function ($m) { - return '[^\/]+'; - }, - $key2 - ); - - return self::regexMatch($key1, '^' . $key2 . '$'); - } - - /** - * The wrapper for KeyMatch2. - * - * @param mixed ...$args - * - * @return bool - */ - public static function keyMatch2Func(...$args): bool - { - $name1 = $args[0]; - $name2 = $args[1]; - - return self::keyMatch2($name1, $name2); - } - - /** - * KeyGet2 returns value matched pattern - * For example, "/resource1" matches "/:resource" - * if the pathVar == "resource", then "resource1" will be returned - * - * @param string $key1 - * @param string $key2 - * @param string $pathVar - * @return string - */ - public static function keyGet2(string $key1, string $key2, string $pathVar): string - { - $key2 = str_replace(['/*'], ['/.*'], $key2); - - $pattern = '/:[^\/]+/'; - $keys = []; - preg_match_all($pattern, $key2, $keys); - $keys = $keys[0]; - $key2 = preg_replace_callback( - $pattern, - function ($m) { - return '([^\/]+)'; - }, - $key2 - ); - - $key2 = "~^" . $key2 . "$~"; - $values = []; - preg_match($key2, $key1, $values); - - if (count($values) === 0) { - return ''; - } - foreach ($keys as $i => $key) { - if ($pathVar == substr($key, 1)) { - return $values[$i + 1]; - } - } - return ''; - } - - /** - * KeyGet2Func is the wrapper for KeyGet2 - * - * @param mixed ...$args - * @return string - */ - public static function keyGet2Func(...$args) - { - $name1 = $args[0]; - $name2 = $args[1]; - $key = $args[2]; - - return self::keyGet2($name1, $name2, $key); - } - - /** - * Determines whether key1 matches the pattern of key2 (similar to RESTful path), key2 can contain a *. - * For example, "/foo/bar" matches "/foo/*", "/resource1" matches "/{resource}". - * - * @param string $key1 - * @param string $key2 - * - * @return bool - */ - public static function keyMatch3(string $key1, string $key2): bool - { - $key2 = str_replace(['/*'], ['/.*'], $key2); - - $pattern = '/\{[^\/]+\}/'; - $key2 = preg_replace_callback( - $pattern, - function ($m) { - return '[^\/]+'; - }, - $key2 - ); - - return self::regexMatch($key1, '^' . $key2 . '$'); - } - - /** - * The wrapper for KeyMatch3. - * - * @param mixed ...$args - * - * @return bool - */ - public static function keyMatch3Func(...$args): bool - { - $name1 = $args[0]; - $name2 = $args[1]; - - return self::keyMatch3($name1, $name2); - } - - /** - * Determines whether key1 matches the pattern of key2 (similar to RESTful path), key2 can contain a *. - * Besides what KeyMatch3 does, KeyMatch4 can also match repeated patterns: - * "/parent/123/child/123" matches "/parent/{id}/child/{id}" - * "/parent/123/child/456" does not match "/parent/{id}/child/{id}" - * But KeyMatch3 will match both. - * - * @param string $key1 - * @param string $key2 - * - * @return bool - */ - public static function keyMatch4(string $key1, string $key2): bool - { - $key2 = str_replace(['/*'], ['/.*'], $key2); - - $tokens = []; - $pattern = '/\{([^\/]+)\}/'; - $key2 = preg_replace_callback( - $pattern, - function ($m) use (&$tokens) { - $tokens[] = $m[1]; - return '([^\/]+)'; - }, - $key2 - ); - - $matched = preg_match_all('~^' . $key2 . '$~', $key1, $matches); - if (!boolval($matched)) { - return false; - } - - $values = []; - foreach ($tokens as $key => $token) { - if (!isset($values[$token])) { - $values[$token] = $matches[$key + 1]; - } - if ($values[$token] != $matches[$key + 1]) { - return false; - } - } - - return true; - } - - /** - * The wrapper for KeyMatch4. - * - * @param mixed ...$args - * - * @return bool - */ - public static function keyMatch4Func(...$args): bool - { - $name1 = $args[0]; - $name2 = $args[1]; - - return self::keyMatch4($name1, $name2); - } - - /** - * Determines whether key1 matches the pattern of key2 and ignores the parameters in key2. - * For example, "/foo/bar?status=1&type=2" matches "/foo/bar" - * - * @param string $key1 - * @param string $key2 - * - * @return bool - */ - public static function keyMatch5(string $key1, string $key2): bool - { - $pos = strpos($key1, '?'); - if ($pos === false) { - return $key1 == $key2; - } - - return substr($key1, 0, $pos) == $key2; - } - - /** - * the wrapper for KeyMatch5. - * - * @param mixed ...$args - * - * @return bool - */ - public static function keyMatch5Func(...$args): bool - { - $name1 = $args[0]; - $name2 = $args[1]; - - return self::keyMatch5($name1, $name2); - } - - /** - * Determines whether key1 matches the pattern of key2 in regular expression. - * - * @param string $key1 - * @param string $key2 - * - * @return bool - */ - public static function regexMatch(string $key1, string $key2): bool - { - return (bool)preg_match('~' . $key2 . '~', $key1); - } - - /** - * The wrapper for RegexMatch. - * - * @param mixed ...$args - * - * @return bool - */ - public static function regexMatchFunc(...$args): bool - { - $name1 = $args[0]; - $name2 = $args[1]; - - return self::regexMatch($name1, $name2); - } - - /** - * Determines whether IP address ip1 matches the pattern of IP address ip2, ip2 can be an IP address or a CIDR - * pattern. - * - * @param string $ip1 - * @param string $ip2 - * - * @return bool - * - * @throws Exception - */ - public static function ipMatch(string $ip1, string $ip2): bool - { - return isIpInCidrRange($ip1, $ip2); - } - - /** - * The wrapper for IPMatch. - * - * @param mixed ...$args - * - * @return bool - * - * @throws Exception - */ - public static function ipMatchFunc(...$args): bool - { - $ip1 = $args[0]; - $ip2 = $args[1]; - - return self::ipMatch($ip1, $ip2); - } - - /** - * Returns true if the specified `string` matches the given glob `pattern`. - * - * @param string $str - * @param string $pattern - * - * @return bool - * - * @throws Exception - */ - public static function globMatch(string $str, string $pattern): bool - { - return fnmatch($pattern, $str, FNM_PATHNAME | FNM_PERIOD); - } - - /** - * The wrapper for globMatch. - * - * @param mixed ...$args - * - * @return bool - * - * @throws Exception - */ - public static function globMatchFunc(...$args): bool - { - $str = $args[0]; - $pattern = $args[1]; - - return self::globMatch($str, $pattern); - } - - /** - * The factory method of the g(_, _) function. - * - * @param RoleManager|null $rm - * - * @return Closure - */ - public static function generateGFunction(RoleManager|null $rm = null): Closure - { - $memorized = []; - return function (...$args) use ($rm, &$memorized) { - $key = implode(chr(0b0), $args); - - if (isset($memorized[$key])) { - return $memorized[$key]; - } - - $name1 = $args[0]; - $name2 = $args[1]; - - if (null === $rm) { - $v = $name1 == $name2; - } elseif (2 == count($args)) { - $v = $rm->hasLink($name1, $name2); - } else { - $domain = (string)$args[2]; - $v = $rm->hasLink($name1, $name2, $domain); - } - - $memorized[$key] = $v; - return $v; - }; - } - - /** - * The factory method of the g(_, _[, _]) function with conditions. - * - * @param ConditionalRoleManager|null $crm - * - * @return Closure - */ - public static function generateConditionalGFunction(ConditionalRoleManager|null $crm = null): Closure - { - return function (...$args) use ($crm) { - $name1 = $args[0]; - $name2 = $args[1]; - - if (is_null($crm)) { - $v = $name1 == $name2; - } elseif (2 == count($args)) { - $v = $crm->hasLink($name1, $name2); - } else { - $domain = (string)$args[2]; - $v = $crm->hasLink($name1, $name2, $domain); - } - - return $v; - }; - } - - /** - * The wrapper for timeMatch. - * - * @param mixed ...$args - * - * @return bool - */ - public static function timeMatchFunc(...$args): bool - { - $startTime = $args[0]; - $endTime = $args[1]; - - return self::timeMatch($startTime, $endTime); - } - - /** - * Determines whether the current time is between startTime and endTime. - * You can use "_" to indicate that the parameter is ignored. - * - * @param string $startTime - * @param string $endTime - * - * @return bool - */ - public static function timeMatch(string $startTime, string $endTime): bool - { - $now = new DateTime(); - if ($startTime !== '_') { - if (false === strtotime($startTime)) { - return false; - } - $start = new DateTime($startTime); - if ($now < $start) { - return false; - } - } - if ($endTime !== '_') { - if (false === strtotime($endTime)) { - return false; - } - $end = new DateTime($endTime); - if ($now > $end) { - return false; - } - } - - return true; - } -} diff --git a/plugins/Authorization/Engine/functions.php b/plugins/Authorization/Engine/functions.php deleted file mode 100644 index 088ba41..0000000 --- a/plugins/Authorization/Engine/functions.php +++ /dev/null @@ -1,78 +0,0 @@ - - */ - function extractEvalParameters(string $expression): array - { - preg_match_all('/\beval\(([^)]*)\)/', $expression, $matches); - - return array_values(array_map('trim', $matches[1] ?? [])); - } -} - -if (!function_exists('replaceEvalWithMappings')) { - /** - * Replace each `eval()` with the mapped rule expression, wrapped in - * parentheses so operator precedence is preserved. - * - * @param array $mappings ruleName => rule expression - */ - function replaceEvalWithMappings(string $expression, array $mappings): string - { - return (string) preg_replace_callback( - '/\beval\(([^)]*)\)/', - static function (array $m) use ($mappings): string { - $ruleName = trim($m[1]); - - return isset($mappings[$ruleName]) ? '(' . $mappings[$ruleName] . ')' : $m[0]; - }, - $expression, - ); - } -} diff --git a/plugins/Authorization/Infrastructure/Cli/SeedPolicyCommand.php b/plugins/Authorization/Infrastructure/Cli/SeedPolicyCommand.php deleted file mode 100644 index 5fff255..0000000 --- a/plugins/Authorization/Infrastructure/Cli/SeedPolicyCommand.php +++ /dev/null @@ -1,100 +0,0 @@ -enforcerFactory = $enforcerFactory; - parent::__construct(); - } - - protected function configure(): void - { - $this->name = 'authz:seed'; - $this->description = 'Seed the Casbin policy table from a policy CSV file'; - - $this->addOption('file', '', 'Path to the policy CSV', acceptsValue: true, default: ''); - $this->addOption('dry', '', 'Parse and report without writing'); - } - - protected function handle(): int - { - $file = (string) $this->option('file') ?: $this->defaultFile; - if (!is_readable($file)) { - $this->error("Policy file [{$file}] is not readable."); - - return self::FAILURE; - } - - $policies = []; - $groupings = []; - - foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { - $line = trim($line); - if ($line === '' || str_starts_with($line, '#')) { - continue; - } - - $parts = array_map('trim', explode(',', $line)); - $type = array_shift($parts); - - if ($type === 'p' && \count($parts) >= 3) { - $policies[] = $parts; - } elseif ($type === 'g' && \count($parts) >= 2) { - $groupings[] = $parts; - } - } - - $this->info(\sprintf('Parsed %d policy rule(s) and %d role assignment(s) from %s.', \count($policies), \count($groupings), $file)); - - if ($this->hasOption('dry')) { - $this->info('Dry run — nothing written.'); - - return self::SUCCESS; - } - - $enforcer = ($this->enforcerFactory)(); - - $added = 0; - foreach ($policies as $rule) { - if ($enforcer->addPolicy(...$rule)) { - $added++; - } - } - foreach ($groupings as $rule) { - if ($enforcer->addGroupingPolicy(...$rule)) { - $added++; - } - } - - $skipped = (\count($policies) + \count($groupings)) - $added; - $this->info("Seeded {$added} new rule(s); {$skipped} already present."); - - return self::SUCCESS; - } -} diff --git a/plugins/Authorization/Infrastructure/Http/Stages/PolicyFilterStage.php b/plugins/Authorization/Infrastructure/Http/Stages/PolicyFilterStage.php deleted file mode 100644 index 4537439..0000000 --- a/plugins/Authorization/Infrastructure/Http/Stages/PolicyFilterStage.php +++ /dev/null @@ -1,63 +0,0 @@ -userId, object, action) against the - * Casbin policy. FAIL-CLOSED: a guest, a missing enforcer (the route forgot to - * require authorization.policy), or a deny all yield an error response — - * never a pass-through. - */ -final class PolicyFilterStage implements HttpStageContract -{ - public function handle(Request $request, callable $next): Response - { - $args = (array) ($request->attribute('filter_args')['can'] ?? []); - $object = trim((string) ($args[0] ?? '')); - $action = trim((string) ($args[1] ?? '')); - - if ($object === '' || $action === '') { - // A malformed filter declaration is a config bug — fail closed loudly. - return Response::serverError(); - } - - $identity = $request->identity(); - if ($identity === null || $identity->isGuest()) { - return Response::unauthorized('Authentication required.'); - } - - $container = $request->container(); - if ($container === null || !$container->has(AuthorizationServiceContract::class)) { - // Policy module not loaded for this route → the declaration is - // incomplete (missing "requires": ["authorization.policy"]). - return Response::json(['error' => [ - 'code' => 'authorization.unavailable', - 'message' => 'This route declares a policy filter but the authorization module is not loaded.', - ]], 500); - } - - $authz = $container->make(AuthorizationServiceContract::class); - if (!$authz instanceof AuthorizationServiceContract - || !$authz->allows($identity->userId, $object, $action)) { - return Response::forbidden('You are not allowed to perform this action.'); - } - - return $next($request); - } -} diff --git a/plugins/Authorization/Infrastructure/Persistence/DatabasePolicyAdapter.php b/plugins/Authorization/Infrastructure/Persistence/DatabasePolicyAdapter.php deleted file mode 100644 index e1f69a3..0000000 --- a/plugins/Authorization/Infrastructure/Persistence/DatabasePolicyAdapter.php +++ /dev/null @@ -1,200 +0,0 @@ -db->query("SELECT ptype, v0, v1, v2, v3, v4, v5 FROM {$this->table}"); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to load Casbin policy', layer: 'repository.authorization', previous: $e); - } - - foreach ($rows as $row) { - $this->loadPolicyArray($this->filterRule($row), $model); - } - } - - public function loadFilteredPolicy(Model $model, $filter): void - { - if ($filter === null) { - $this->loadPolicy($model); - return; - } - if (!$filter instanceof Filter) { - throw new \InvalidArgumentException('Invalid filter type for DatabasePolicyAdapter.'); - } - - try { - $rows = $this->db->query("SELECT ptype, v0, v1, v2, v3, v4, v5 FROM {$this->table}"); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to load filtered Casbin policy', layer: 'repository.authorization', previous: $e); - } - - foreach ($rows as $row) { - $rule = $this->filterRule($row); - if ($this->matchesFilter($rule, $filter)) { - $this->loadPolicyArray($rule, $model); - } - } - - $this->filtered = true; - } - - public function isFiltered(): bool - { - return $this->filtered; - } - - public function savePolicy(Model $model): void - { - try { - $this->db->execute("DELETE FROM {$this->table}"); - - foreach (($model['p'] ?? []) as $ptype => $ast) { - foreach ($ast->policy as $rule) { - $this->insertRow($ptype, $rule); - } - } - foreach (($model['g'] ?? []) as $ptype => $ast) { - foreach ($ast->policy as $rule) { - $this->insertRow($ptype, $rule); - } - } - } catch (\PDOException $e) { - throw new RepositoryException('Failed to save Casbin policy', layer: 'repository.authorization', previous: $e); - } - } - - public function addPolicy(string $sec, string $ptype, array $rule): void - { - $this->insertRow($ptype, $rule); - } - - public function addPolicies(string $sec, string $ptype, array $rules): void - { - foreach ($rules as $rule) { - $this->insertRow($ptype, $rule); - } - } - - public function removePolicy(string $sec, string $ptype, array $rule): void - { - $where = ['ptype' => $ptype]; - foreach (array_values($rule) as $i => $value) { - $where["v{$i}"] = $value; - } - $this->deleteWhere($where); - } - - public function removePolicies(string $sec, string $ptype, array $rules): void - { - foreach ($rules as $rule) { - $this->removePolicy($sec, $ptype, $rule); - } - } - - public function removeFilteredPolicy(string $sec, string $ptype, int $fieldIndex, string ...$fieldValues): void - { - $where = ['ptype' => $ptype]; - foreach ($fieldValues as $i => $value) { - if ($value !== '') { - $where['v' . ($fieldIndex + $i)] = $value; - } - } - $this->deleteWhere($where); - } - - /** @param array $rule */ - private function insertRow(string $ptype, array $rule): void - { - $cols = ['ptype']; - $params = ['ptype' => $ptype]; - foreach (array_values($rule) as $i => $value) { - $cols[] = "v{$i}"; - $params["v{$i}"] = $value; - } - $placeholders = array_map(static fn(string $c) => ":{$c}", $cols); - $sql = "INSERT INTO {$this->table} (" . implode(', ', $cols) . ') VALUES (' . implode(', ', $placeholders) . ')'; - $this->db->execute($sql, $params); - } - - /** @param array $where */ - private function deleteWhere(array $where): void - { - $clauses = []; - foreach (array_keys($where) as $col) { - $clauses[] = "{$col} = :{$col}"; - } - $sql = "DELETE FROM {$this->table} WHERE " . implode(' AND ', $clauses); - $this->db->execute($sql, $where); - } - - /** - * Reduce a DB row to a trimmed rule array (drops null/empty trailing columns). - * - * @param array $row - * @return array - */ - private function filterRule(array $row): array - { - $rule = [$row['ptype']]; - for ($i = 0; $i <= 5; $i++) { - $val = $row["v{$i}"] ?? null; - if ($val === null || $val === '') { - break; - } - $rule[] = $val; - } - return $rule; - } - - /** - * @param array $rule full rule including ptype at index 0 - */ - private function matchesFilter(array $rule, Filter $filter): bool - { - $ptype = $rule[0]; - $values = array_slice($rule, 1); - $criteria = $ptype === 'p' ? $filter->p : ($ptype === 'g' ? $filter->g : []); - - foreach ($criteria as $i => $expected) { - if ($expected !== '' && ($values[$i] ?? null) !== $expected) { - return false; - } - } - return true; - } -} diff --git a/plugins/Authorization/Provider.php b/plugins/Authorization/Provider.php deleted file mode 100644 index 42cd45c..0000000 --- a/plugins/Authorization/Provider.php +++ /dev/null @@ -1,108 +0,0 @@ - */ - public function requires(): array - { - return ['database.management']; - } - - /** @return list */ - public function exposes(): array - { - return [AuthorizationServiceContract::class]; - } - - public function register(ModuleContainer $container): void - { - // Casbin policy storage adapter. Policy rules are CONTROL-PLANE data - // (roles/permissions are global, not tenant data), so pin to the central - // connection — the same store the authz:seed CLI writes to, so seeded - // policies are visible to runtime enforcement. - $container->bindInternal(DatabasePolicyAdapter::class, static fn(ModuleContainer $c) => - new DatabasePolicyAdapter( - $c->make(DatabasePort::class), - env('AUTHZ_POLICY_TABLE') ?: 'casbin_rule', - ) - ); - - // The Casbin Enforcer — internal, built from the model config + DB adapter. - $container->bindInternal(Enforcer::class, static function (ModuleContainer $c) { - $modelPath = env('AUTHZ_MODEL_PATH') ?: __DIR__ . '/config/rbac_model.conf'; - return new Enforcer($modelPath, $c->make(DatabasePolicyAdapter::class)); - }); - - // Published contract. - $container->bind(AuthorizationServiceContract::class, static fn(ModuleContainer $c) => - new AuthorizationService($c->make(Enforcer::class)) - ); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Declarative route filter: "filters": ["can:users,edit"] enforces the - // Casbin policy for the route (the route must also carry - // "requires": ["authorization.policy"] so this module is loaded). - $http->filter('can', \Plugins\Authorization\Infrastructure\Http\Stages\PolicyFilterStage::class); - - // authz:seed — import a policy CSV into the DB policy table. Deferred so - // only CLI processes pay for it; builds its own enforcer over the - // central connection (policy rules are control-plane data). - $cli->defer(static function (CliPipeline $cli): void { - $c = new ModuleContainer($cli->container()); - $c->setScope('database.management'); - (new \Plugins\Database\Provider())->register($c); - - // Lazy: building an Enforcer loads policy from the DB, so defer it - // until the command actually runs (not at CLI registration time). - $enforcerFactory = static function () use ($c): Enforcer { - $adapter = new DatabasePolicyAdapter( - $c->make(\Plugins\Database\API\Contracts\DatabaseConnectionManagerContract::class)->default(), - env('AUTHZ_POLICY_TABLE') ?: 'casbin_rule', - ); - - return new Enforcer( - env('AUTHZ_MODEL_PATH') ?: __DIR__ . '/config/rbac_model.conf', - $adapter, - ); - }; - - $cli->command(new \Plugins\Authorization\Infrastructure\Cli\SeedPolicyCommand( - $enforcerFactory, - __DIR__ . '/config/policy.seed.csv', - )); - }); - } -} diff --git a/plugins/Authorization/config/policy.seed.csv b/plugins/Authorization/config/policy.seed.csv deleted file mode 100644 index f38bd7b..0000000 --- a/plugins/Authorization/config/policy.seed.csv +++ /dev/null @@ -1,175 +0,0 @@ -# ============================================= -# SUPER ROLE - Full access -# ============================================= -p, super, *, * - -# ============================================= -# OWNER ROLE - Full access except system-level super controls -# ============================================= -p, owner, *, * - -# ============================================= -# ADMIN ROLE - Module-level full access -# ============================================= -p, admin, users, * -p, admin, roles, * -p, admin, permissions, * -p, admin, products, * -p, admin, inventory, * -p, admin, orders, * -p, admin, customers, * -p, admin, suppliers, * -p, admin, payments, * -p, admin, refunds, * -p, admin, reports, * -p, admin, settings, * -p, admin, discounts, * -p, admin, giftcards, * -p, admin, tax, * -p, admin, branches, * -p, admin, integrations, * -p, admin, webhooks, * -p, admin, pos_terminal, * -p, admin, dashboard, read -p, admin, audit_logs, read - -# ============================================= -# MANAGER ROLE - Day-to-day operations -# ============================================= -p, manager, products, * -p, manager, inventory, * -p, manager, orders, * -p, manager, customers, * -p, manager, refunds, * -p, manager, discounts, * -p, manager, giftcards, * -p, manager, pos_terminal, * -p, manager, reports, read -p, manager, dashboard, read - -# ============================================= -# SUPERVISOR ROLE - Limited operations -# ============================================= -p, supervisor, products, read -p, supervisor, inventory, * -p, supervisor, orders, read -p, supervisor, refunds, create -p, supervisor, pos_terminal, * -p, supervisor, reports, read -p, supervisor, dashboard, read - -# ============================================= -# CASHIER ROLE - POS operations -# ============================================= -p, cashier, pos_terminal, * -p, cashier, orders, create -p, cashier, orders, checkout -p, cashier, orders, read -p, cashier, refunds, create -p, cashier, customers, create -p, cashier, customers, read -p, cashier, payments, create -p, cashier, products, read -p, cashier, dashboard, read - -# ============================================= -# INVENTORY CLERK ROLE - Stock management -# ============================================= -p, inventory_clerk, products, read -p, inventory_clerk, inventory, * -p, inventory_clerk, suppliers, read -p, inventory_clerk, reports, read - -# ============================================= -# ACCOUNTANT ROLE - Finance and auditing -# ============================================= -p, accountant, payments, * -p, accountant, refunds, * -p, accountant, reports, * -p, accountant, tax, * -p, accountant, audit_logs, read -p, accountant, dashboard, read - -# ============================================= -# SUPPORT ROLE - Customer support -# ============================================= -p, support, customers, read -p, support, orders, read -p, support, refunds, read -p, support, audit_logs, read -p, support, dashboard, read - -# ============================================= -# VIEWER ROLE - Read-only access -# ============================================= -p, viewer, dashboard, read -p, viewer, reports, read -p, viewer, products, read -p, viewer, inventory, read -p, viewer, orders, read -p, viewer, customers, read - -# ============================================= -# HKMRENTAL — GUEST -# Unauthenticated visitors; read-only public listing surface. -# ============================================= -p, guest, rental.listings, read - -# ============================================= -# HKMRENTAL — TENANT -# Registered user looking to rent a property. -# - Browse and search listings -# - Manage own wishlist, bookings, and inquiries -# - View own payment history -# - Full account self-management (profile, notifications, security, etc.) -# - Messaging with landlords and brokers -# ============================================= -p, tenant, rental.listings, read -p, tenant, rental.account, * -p, tenant, rental.chat, * -p, tenant, rental.wishlist, * -p, tenant, rental.booking, * -p, tenant, rental.inquiry, write -p, tenant, rental.payment, read - -# ============================================= -# HKMRENTAL — LANDLORD -# Property owner; manages their own units, tenants, and rent collection. -# - All tenant-facing browsing rights (market awareness) -# - Full landlord module: dashboard, properties, tenants, schedules, -# receipts, payments, profile, pulse heartbeat -# - Full account self-management -# - Messaging -# ============================================= -p, landlord, rental.listings, read -p, landlord, rental.account, * -p, landlord, rental.chat, * -p, landlord, rental.landlord, * - -# ============================================= -# HKMRENTAL — BROKER -# Licensed agent; manages listings, showings, and client relationships. -# - Public listing browsing (market awareness) -# - Full broker module: dashboard, listings, showings, clients, -# commission, landlord search & connection, profile -# - Full account self-management -# - Messaging -# ============================================= -p, broker, rental.listings, read -p, broker, rental.account, * -p, broker, rental.chat, * -p, broker, rental.broker, * - -# ============================================= -# HKMRENTAL — PROPERTY_MANAGER -# Company managing multiple landlords' portfolios. -# - Public listing browsing (market awareness) -# - Full company module: dashboard, landlords, properties, -# maintenance queue, financial summaries, reports -# - Full account self-management -# - Messaging -# ============================================= -p, property_manager, rental.listings, read -p, property_manager, rental.account, * -p, property_manager, rental.chat, * -p, property_manager, rental.company, * \ No newline at end of file diff --git a/plugins/Authorization/config/rbac_model.conf b/plugins/Authorization/config/rbac_model.conf deleted file mode 100644 index fa56a93..0000000 --- a/plugins/Authorization/config/rbac_model.conf +++ /dev/null @@ -1,17 +0,0 @@ -[request_definition] -r = sub, obj, act - -[policy_definition] -p = sub, obj, act - -[role_definition] -g = _, _ - -[policy_effect] -e = some(where (p.eft == allow)) - -# Wildcard-aware matcher (old __DEV__ model): a policy may grant "*" as the -# object and/or action, so "p, super, *, *" is full access and -# "p, admin, users, *" is module-wide access. -[matchers] -m = g(r.sub, p.sub) && (p.obj == "*" || r.obj == p.obj) && (p.act == "*" || r.act == p.act) diff --git a/plugins/Authorization/database/migrations/.gitkeep b/plugins/Authorization/database/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/Authorization/database/tenant-template/2026_06_05_000001_create_casbin_rule_table.php b/plugins/Authorization/database/tenant-template/2026_06_05_000001_create_casbin_rule_table.php deleted file mode 100644 index f261a79..0000000 --- a/plugins/Authorization/database/tenant-template/2026_06_05_000001_create_casbin_rule_table.php +++ /dev/null @@ -1,29 +0,0 @@ -create('casbin_rule', static function ($t) { - $t->id(); - $t->string('ptype', 32); - $t->string('v0', 255)->nullable(); - $t->string('v1', 255)->nullable(); - $t->string('v2', 255)->nullable(); - $t->string('v3', 255)->nullable(); - $t->string('v4', 255)->nullable(); - $t->string('v5', 255)->nullable(); - - $t->index(['ptype']); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('casbin_rule'); - } -}; diff --git a/plugins/Authorization/module.json b/plugins/Authorization/module.json deleted file mode 100644 index 4b7f0b0..0000000 --- a/plugins/Authorization/module.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "authorization", - "version": "1.0.0", - "solves": "authorization.policy", - "type": "module", - - "requires": ["database.management"], - "exposes": ["Plugins\\Authorization\\API\\Contracts\\AuthorizationServiceContract"], - - "routes": [], - "emits": [], - "listens": [], - - "config": [ - { "key": "AUTHZ_MODEL_PATH", "type": "string", "required": false }, - { "key": "AUTHZ_POLICY_TABLE", "type": "string", "required": false } - ] -} diff --git a/plugins/Commands/API/Contracts/MigrationServiceContract.php b/plugins/Commands/API/Contracts/MigrationServiceContract.php deleted file mode 100644 index 364804b..0000000 --- a/plugins/Commands/API/Contracts/MigrationServiceContract.php +++ /dev/null @@ -1,49 +0,0 @@ -option('config') ? (string) $command->option('config') : null, - pretend: $command->hasOption('pretend'), - steps: (int) ($command->option('steps') ?? 0), - force: $command->hasOption('force'), - targetVersion: $command->option('target') ? (string) $command->option('target') : null, - ); - } -} diff --git a/plugins/Commands/API/DTOs/MigrateResponse.php b/plugins/Commands/API/DTOs/MigrateResponse.php deleted file mode 100644 index b46bd84..0000000 --- a/plugins/Commands/API/DTOs/MigrateResponse.php +++ /dev/null @@ -1,48 +0,0 @@ - $this->success, - 'message' => $this->message, - 'migrations_run' => $this->migrationsRun, - 'details' => $this->details, - 'error' => $this->error, - ]; - } -} diff --git a/plugins/Commands/API/DTOs/MigrateStatusRequest.php b/plugins/Commands/API/DTOs/MigrateStatusRequest.php deleted file mode 100644 index 772fe50..0000000 --- a/plugins/Commands/API/DTOs/MigrateStatusRequest.php +++ /dev/null @@ -1,21 +0,0 @@ -option('config') ? (string) $command->option('config') : null, - ); - } -} diff --git a/plugins/Commands/API/DTOs/MigrateStatusResponse.php b/plugins/Commands/API/DTOs/MigrateStatusResponse.php deleted file mode 100644 index b6781ca..0000000 --- a/plugins/Commands/API/DTOs/MigrateStatusResponse.php +++ /dev/null @@ -1,35 +0,0 @@ - $this->applied, - 'pending' => $this->pending, - 'applied_count' => $this->appliedCount, - 'pending_count' => $this->pendingCount, - ]; - } -} diff --git a/plugins/Commands/API/DTOs/ModuleAddRequest.php b/plugins/Commands/API/DTOs/ModuleAddRequest.php deleted file mode 100644 index f1af926..0000000 --- a/plugins/Commands/API/DTOs/ModuleAddRequest.php +++ /dev/null @@ -1,69 +0,0 @@ -validate(); - } - - public static function fromInput(AbstractCommand $command): self - { - return new self( - name: (string) $command->argument('name'), - gitUrl: (string) $command->argument('git-url'), - org: (string) $command->argument('org'), - offline: $command->hasOption('offline'), - ); - } - - private function validate(): void - { - if (empty($this->name)) { - throw new \DomainException('Module name cannot be empty'); - } - if (empty($this->gitUrl)) { - throw new \DomainException('Git URL cannot be empty'); - } - if (empty($this->org)) { - throw new \DomainException('Organization name cannot be empty'); - } - if (!preg_match('/^[a-z0-9\-]+$/', $this->name)) { - throw new \DomainException('Module name must be kebab-case (a-z, 0-9, hyphens only)'); - } - } - - public function toPascalCase(string $text): string - { - return str_replace( - ' ', - '', - ucwords(str_replace('-', ' ', $text)) - ); - } - - public function getPackageName(): string - { - return "{$this->org}/{$this->name}"; - } - - public function getNamespace(): string - { - return $this->toPascalCase($this->org) . '\\' . $this->toPascalCase($this->name) . '\\'; - } - - public function getModulePath(): string - { - return "modules/{$this->name}"; - } -} diff --git a/plugins/Commands/API/DTOs/ModuleAddResponse.php b/plugins/Commands/API/DTOs/ModuleAddResponse.php deleted file mode 100644 index 3fe5890..0000000 --- a/plugins/Commands/API/DTOs/ModuleAddResponse.php +++ /dev/null @@ -1,52 +0,0 @@ - $this->success, - 'message' => $this->message, - 'module_path' => $this->modulePath, - 'package_name' => $this->packageName, - 'namespace' => $this->namespace, - 'error' => $this->error, - ]; - } -} diff --git a/plugins/Commands/API/DTOs/ModuleRemoveRequest.php b/plugins/Commands/API/DTOs/ModuleRemoveRequest.php deleted file mode 100644 index d89e780..0000000 --- a/plugins/Commands/API/DTOs/ModuleRemoveRequest.php +++ /dev/null @@ -1,40 +0,0 @@ -validate(); - } - - public static function fromInput(AbstractCommand $command): self - { - return new self( - name: (string) $command->argument('name'), - force: $command->hasOption('force'), - ); - } - - private function validate(): void - { - if (empty($this->name)) { - throw new \DomainException('Module name cannot be empty'); - } - if (!preg_match('/^[a-z0-9\-]+$/', $this->name)) { - throw new \DomainException('Module name must be kebab-case (a-z, 0-9, hyphens only)'); - } - } - - public function getModulePath(): string - { - return "modules/{$this->name}"; - } -} diff --git a/plugins/Commands/API/DTOs/ModuleRemoveResponse.php b/plugins/Commands/API/DTOs/ModuleRemoveResponse.php deleted file mode 100644 index 4d9fab1..0000000 --- a/plugins/Commands/API/DTOs/ModuleRemoveResponse.php +++ /dev/null @@ -1,44 +0,0 @@ - $this->success, - 'message' => $this->message, - 'module_name' => $this->moduleName, - 'error' => $this->error, - ]; - } -} diff --git a/plugins/Commands/Application/Services/CommandsInfrastructureService.php b/plugins/Commands/Application/Services/CommandsInfrastructureService.php deleted file mode 100644 index 96e88d9..0000000 --- a/plugins/Commands/Application/Services/CommandsInfrastructureService.php +++ /dev/null @@ -1,287 +0,0 @@ -deploymentLocks->isLocked($lockKey); - } - - public function getDeploymentLockHolder(string $lockKey): ?string - { - return $this->deploymentLocks->getLockHolder($lockKey); - } - - public function createDeploymentLock(string $lockKey, string $holder, string $expiresAt): void - { - $this->deploymentLocks->createLock($lockKey, $holder, $expiresAt); - } - - public function deleteDeploymentLock(string $lockKey): void - { - $this->deploymentLocks->deleteLock($lockKey); - } - - public function cleanupExpiredDeploymentLocks(): void - { - $this->deploymentLocks->cleanupExpiredLocks(); - } - - // ════════════════════════════════════════════════════════════════ - // COMMAND AUDIT LOGGING API - // ════════════════════════════════════════════════════════════════ - - public function logCommandStart( - string $command, - string $user, - string $hostname, - int $pid, - array $arguments, - ): string { - return $this->auditLogs->logStart($command, $user, $hostname, $pid, $arguments); - } - - public function logCommandEnd( - string $logId, - int $exitCode, - int $durationMs, - ?string $errorMessage = null, - ): void { - $this->auditLogs->logEnd($logId, $exitCode, $durationMs, $errorMessage); - } - - public function logMigrationOperation( - string $logId, - string $migrationName, - string $direction, - bool $success, - ?string $errorMessage = null, - ): void { - $this->auditLogs->logMigration($logId, $migrationName, $direction, $success, $errorMessage); - } - - public function logDestructiveCommandOperation( - string $logId, - string $operationName, - string $details, - ): void { - $this->auditLogs->logDestructiveOperation($logId, $operationName, $details); - } - - public function getRecentCommandLogs(int $limit = 20): array - { - return $this->auditLogs->getRecentLogs($limit); - } - - // ════════════════════════════════════════════════════════════════ - // BACKUP MANAGEMENT API - // ════════════════════════════════════════════════════════════════ - - public function recordBackup( - string $database, - string $backupPath, - string $filename, - int $fileSizeBytes, - ): void { - $this->backups->recordBackup($database, $backupPath, $filename, $fileSizeBytes); - } - - public function listBackups(string $database, int $limit = 10): array - { - return $this->backups->listBackups($database, $limit); - } - - public function getBackup(string $filename): ?array - { - return $this->backups->getBackup($filename); - } - - public function deleteOldBackupRecords(int $daysOld = 30): int - { - return $this->backups->deleteOldBackupRecords($daysOld); - } - - // ════════════════════════════════════════════════════════════════ - // MIGRATION APPROVAL API - // ════════════════════════════════════════════════════════════════ - - public function createApprovalRequest( - string $approvalId, - array $migrations, - string $requester, - ): void { - $this->approvals->createApprovalRequest($approvalId, $migrations, $requester); - } - - public function getApprovalRequest(string $approvalId): ?array - { - return $this->approvals->getApprovalRequest($approvalId); - } - - public function approveRequest( - string $approvalId, - string $approver, - ?string $notes = null, - ): void { - $this->approvals->approve($approvalId, $approver, $notes); - } - - public function rejectRequest( - string $approvalId, - string $rejector, - string $reason, - ): void { - $this->approvals->reject($approvalId, $rejector, $reason); - } - - public function getPendingApprovalRequests(): array - { - return $this->approvals->getPendingApprovals(); - } - - public function hasPendingApproval(int $timeoutSeconds = 3600): bool - { - return $this->approvals->hasPendingApproval($timeoutSeconds); - } - - // ════════════════════════════════════════════════════════════════ - // PRE-FLIGHT VALIDATION API - // ════════════════════════════════════════════════════════════════ - - public function isDatabaseAccessible(array $config): bool - { - return $this->migrations->isDatabaseAccessible($config); - } - - public function doesTrackingTableExist(array $config): bool - { - return $this->migrations->doesTrackingTableExist($config); - } - - public function loadMigrationConfiguration(?string $configPath = null): array - { - return $this->migrations->loadConfiguration($configPath); - } - - // ════════════════════════════════════════════════════════════════ - // MIGRATION OPERATIONS API - // ════════════════════════════════════════════════════════════════ - - public function getMigrationStatus(array $config): array - { - return $this->migrations->getStatus($config); - } - - public function runPendingMigrations(array $config): array - { - return $this->migrations->runPending($config); - } - - public function rollbackMigrations(array $config, int $steps = 1): array - { - return $this->migrations->rollback($config, $steps); - } - - public function resetAllMigrations(array $config): array - { - return $this->migrations->reset($config); - } - - public function refreshAllMigrations(array $config): array - { - return $this->migrations->refresh($config); - } - - // ════════════════════════════════════════════════════════════════ - // MODULE MANAGEMENT API - // ════════════════════════════════════════════════════════════════ - - public function addModule($request) - { - return $this->modules->add($request); - } - - public function removeModule($request) - { - return $this->modules->remove($request); - } - - // ════════════════════════════════════════════════════════════════ - // DIRECT REPOSITORY ACCESS (for advanced use cases) - // ════════════════════════════════════════════════════════════════ - - public function deploymentLocks(): DeploymentLockRepository - { - return $this->deploymentLocks; - } - - public function auditLogs(): CommandAuditLogRepository - { - return $this->auditLogs; - } - - public function backups(): BackupRepository - { - return $this->backups; - } - - public function approvals(): ApprovalRepository - { - return $this->approvals; - } - - public function migrations(): MigrationRepository - { - return $this->migrations; - } - - public function modules(): ModuleRepository - { - return $this->modules; - } -} diff --git a/plugins/Commands/Application/Services/MigrationService.php b/plugins/Commands/Application/Services/MigrationService.php deleted file mode 100644 index 549b686..0000000 --- a/plugins/Commands/Application/Services/MigrationService.php +++ /dev/null @@ -1,248 +0,0 @@ -logger->logStart('migrate:run', [$request->configPath ?? 'default']); - - try { - $this->lockManager->acquireLock(); - - try { - // Pre-flight validation - $config = $this->repository->loadConfiguration($request->configPath); - $report = $this->validator->validate($config); - - if ($report->hasErrors()) { - throw ServiceException::migrationFailed( - 'Pre-flight validation failed: ' . implode(', ', $report->getErrors()) - ); - } - - // Check approvals if required - if ($config['require_approval'] ?? false) { - $this->approvalManager->checkApproval(); - } - - // Create backup if required - if ($config['require_backup'] ?? false) { - $this->backupManager->createBackup($config); - } - - // Run migrations - $result = $this->repository->runPending($config); - - $this->logger->logMigration('migrate:run', 'up', true); - $this->logger->logEnd(0); - - return MigrateResponse::success(count($result), [ - 'migrations' => $result, - ]); - } finally { - $this->lockManager->releaseLock(); - } - } catch (ServiceException $e) { - $this->logger->logMigration('migrate:run', 'up', false, $e->getMessage()); - $this->logger->logEnd(1, $e->getMessage()); - throw $e; - } catch (\Throwable $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw ServiceException::migrationFailed($e->getMessage()); - } - } - - /** - * Rollback the last N migration batches. - * - * Similar to runMigrations but for rollback direction. - */ - public function rollbackMigrations(MigrateRequest $request): MigrateResponse - { - $this->logger->logStart('migrate:rollback', [$request->steps]); - - try { - $this->lockManager->acquireLock(); - - try { - $config = $this->repository->loadConfiguration($request->configPath); - $report = $this->validator->validate($config); - - if ($report->hasErrors()) { - throw ServiceException::migrationFailed('Pre-flight validation failed'); - } - - // Backup before rollback - if ($config['require_backup'] ?? false) { - $this->backupManager->createBackup($config); - } - - $result = $this->repository->rollback($config, $request->steps); - - $this->logger->logMigration('migrate:rollback', 'down', true); - $this->logger->logEnd(0); - - return MigrateResponse::success(count($result)); - } finally { - $this->lockManager->releaseLock(); - } - } catch (ServiceException $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw $e; - } catch (\Throwable $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw ServiceException::migrationFailed($e->getMessage()); - } - } - - /** - * Get current migration status (read-only, no locks needed). - */ - public function getMigrationStatus(MigrateStatusRequest $request): MigrateStatusResponse - { - try { - $config = $this->repository->loadConfiguration($request->configPath); - $status = $this->repository->getStatus($config); - - return MigrateStatusResponse::fromMigrations( - $status['applied'] ?? [], - $status['pending'] ?? [], - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - 'Failed to get migration status: ' . $e->getMessage() - ); - } - } - - /** - * Reset all migrations (rollback everything). - * This is destructive and requires lock + backup. - */ - public function resetMigrations(MigrateRequest $request): MigrateResponse - { - $this->logger->logStart('migrate:reset', []); - - try { - $this->lockManager->acquireLock(); - - try { - $config = $this->repository->loadConfiguration($request->configPath); - - // Always backup on reset - $this->backupManager->createBackup($config); - - $result = $this->repository->reset($config); - - $this->logger->logDestructiveOperation( - 'migrate:reset', - 'Reset all migrations' - ); - $this->logger->logEnd(0); - - return MigrateResponse::success(count($result)); - } finally { - $this->lockManager->releaseLock(); - } - } catch (ServiceException $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw $e; - } catch (\Throwable $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw ServiceException::migrationFailed($e->getMessage()); - } - } - - /** - * Refresh migrations (reset + re-run all). - * Very destructive — requires lock + backup. - */ - public function refreshMigrations(MigrateRequest $request): MigrateResponse - { - $this->logger->logStart('migrate:refresh', []); - - try { - $this->lockManager->acquireLock(); - - try { - $config = $this->repository->loadConfiguration($request->configPath); - - // Always backup on refresh - $this->backupManager->createBackup($config); - - $result = $this->repository->refresh($config); - - $this->logger->logDestructiveOperation( - 'migrate:refresh', - 'Refresh all migrations (reset + re-run)' - ); - $this->logger->logEnd(0); - - return MigrateResponse::success(count($result)); - } finally { - $this->lockManager->releaseLock(); - } - } catch (ServiceException $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw $e; - } catch (\Throwable $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw ServiceException::migrationFailed($e->getMessage()); - } - } -} diff --git a/plugins/Commands/Application/Services/ModuleManagementService.php b/plugins/Commands/Application/Services/ModuleManagementService.php deleted file mode 100644 index a46ca46..0000000 --- a/plugins/Commands/Application/Services/ModuleManagementService.php +++ /dev/null @@ -1,89 +0,0 @@ -logger->logStart('module:add', [$request->name, $request->gitUrl]); - - try { - $this->lockManager->acquireLock(); - - try { - $response = $this->repository->add($request); - $this->logger->logEnd(0); - return $response; - } finally { - $this->lockManager->releaseLock(); - } - } catch (ServiceException $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw $e; - } catch (\Throwable $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw ServiceException::moduleAddFailed($e->getMessage()); - } - } - - /** - * Remove a git submodule with enterprise safeguards. - * Coordinates: lock → log → remove → release lock - */ - public function removeModule(ModuleRemoveRequest $request): ModuleRemoveResponse - { - $this->logger->logStart('module:remove', [$request->name]); - - try { - $this->lockManager->acquireLock(); - - try { - $response = $this->repository->remove($request); - $this->logger->logEnd(0); - return $response; - } finally { - $this->lockManager->releaseLock(); - } - } catch (ServiceException $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw $e; - } catch (\Throwable $e) { - $this->logger->logEnd(1, $e->getMessage()); - throw ServiceException::moduleRemoveFailed($e->getMessage()); - } - } -} diff --git a/plugins/Commands/Approval/MigrationApprovalManager.php b/plugins/Commands/Approval/MigrationApprovalManager.php deleted file mode 100644 index 37f41a8..0000000 --- a/plugins/Commands/Approval/MigrationApprovalManager.php +++ /dev/null @@ -1,128 +0,0 @@ -generateApprovalId(); - $requester = $this->getCurrentUser(); - - $this->infrastructure->createApprovalRequest($id, $pendingMigrations, $requester); - - return new ApprovalRequest( - id: $id, - migrations: $pendingMigrations, - requester: $requester, - createdAt: date('Y-m-d H:i:s'), - status: 'pending', - ); - } - - public function getApprovalRequest(string $id): ?ApprovalRequest - { - $result = $this->infrastructure->getApprovalRequest($id); - - if (!$result) { - return null; - } - - return new ApprovalRequest( - id: $result['id'], - migrations: $result['migrations'], - requester: $result['requester'], - createdAt: $result['created_at'], - status: $result['status'], - approver: $result['approver'] ?? null, - approvedAt: $result['approved_at'] ?? null, - notes: $result['notes'] ?? null, - ); - } - - public function approve(string $id, ?string $notes = null): void - { - $this->infrastructure->approveRequest($id, $this->getCurrentUser(), $notes); - } - - public function reject(string $id, string $reason): void - { - $this->infrastructure->rejectRequest($id, $this->getCurrentUser(), $reason); - } - - public function getPendingApprovals(): array - { - $results = $this->infrastructure->getPendingApprovalRequests(); - - return array_map( - fn($row) => new ApprovalRequest( - id: $row['id'], - migrations: $row['migrations'], - requester: $row['requester'], - createdAt: $row['created_at'], - status: $row['status'], - ), - $results - ); - } - - private function getCurrentUser(): string - { - return get_current_user() ?: 'unknown'; - } - - private function generateApprovalId(): string - { - return 'approval_' . bin2hex(random_bytes(8)); - } -} - -final class ApprovalRequest -{ - public function __construct( - public readonly string $id, - public readonly array $migrations, - public readonly string $requester, - public readonly string $createdAt, - public readonly string $status = 'pending', - public readonly ?string $approver = null, - public readonly ?string $approvedAt = null, - public readonly ?string $notes = null, - ) {} - - public function isPending(): bool - { - return $this->status === 'pending'; - } - - public function isApproved(): bool - { - return $this->status === 'approved'; - } - - public function isRejected(): bool - { - return $this->status === 'rejected'; - } - - public function getMigrationCount(): int - { - return count($this->migrations); - } -} - -final class ApprovalException extends \RuntimeException -{ - public function __construct(string $message) - { - parent::__construct($message); - } -} diff --git a/plugins/Commands/Backup/BackupManager.php b/plugins/Commands/Backup/BackupManager.php deleted file mode 100644 index 889f4ff..0000000 --- a/plugins/Commands/Backup/BackupManager.php +++ /dev/null @@ -1,193 +0,0 @@ -getMessage()}"); - } - } - - public static function cleanupOldBackups(): int - { - if (!is_dir(self::BACKUP_DIR)) { - return 0; - } - - $cutoffTime = time() - (self::BACKUP_RETENTION_DAYS * 24 * 60 * 60); - $deletedCount = 0; - - foreach (glob(self::BACKUP_DIR . '/database_backup_*.sql') as $file) { - if (filemtime($file) < $cutoffTime) { - if (unlink($file)) { - $deletedCount++; - } - } - } - - return $deletedCount; - } - - public static function listBackups(): array - { - if (!is_dir(self::BACKUP_DIR)) { - return []; - } - - $backups = []; - foreach (glob(self::BACKUP_DIR . '/database_backup_*.sql') as $file) { - $backups[] = new BackupFile( - path: $file, - filename: basename($file), - timestamp: (string) filemtime($file), - size: filesize($file), - ); - } - - // Sort by newest first - usort($backups, fn($a, $b) => $b->timestamp <=> $a->timestamp); - - return $backups; - } - - private static function ensureBackupDirectory(): void - { - if (!is_dir(self::BACKUP_DIR)) { - if (!mkdir(self::BACKUP_DIR, 0755, true)) { - throw new BackupException("Cannot create backup directory: " . self::BACKUP_DIR); - } - } - - if (!is_writable(self::BACKUP_DIR)) { - throw new BackupException("Backup directory is not writable: " . self::BACKUP_DIR); - } - } - - private static function dumpDatabase(array $conn, string $filepath): void - { - $driver = $conn['driver'] ?? 'mysql'; - - $command = match ($driver) { - 'mysql' => self::getMysqlDumpCommand($conn, $filepath), - 'pgsql' => self::getPostgresDumpCommand($conn, $filepath), - 'sqlite' => self::getSqliteDumpCommand($conn, $filepath), - default => throw new BackupException("Backup not supported for driver: {$driver}"), - }; - - $output = []; - $returnCode = 0; - exec($command, $output, $returnCode); - - if ($returnCode !== 0) { - throw new BackupException( - "Backup command failed (exit code {$returnCode}): " . implode("\n", $output) - ); - } - - if (!file_exists($filepath)) { - throw new BackupException("Backup file was not created"); - } - } - - private static function getMysqlDumpCommand(array $conn, string $filepath): string - { - $host = escapeshellarg($conn['host'] ?? 'localhost'); - $user = escapeshellarg($conn['username'] ?? 'root'); - $pass = $conn['password'] ? '-p' . escapeshellarg($conn['password']) : ''; - $database = escapeshellarg($conn['database'] ?? ''); - - return "mysqldump -h{$host} -u{$user} {$pass} {$database} > {$filepath}"; - } - - private static function getPostgresDumpCommand(array $conn, string $filepath): string - { - $host = escapeshellarg($conn['host'] ?? 'localhost'); - $user = escapeshellarg($conn['username'] ?? 'postgres'); - $database = escapeshellarg($conn['database'] ?? 'postgres'); - - $env = "PGPASSWORD=" . escapeshellarg($conn['password'] ?? ''); - - return "{$env} pg_dump -h {$host} -U {$user} {$database} > {$filepath}"; - } - - private static function getSqliteDumpCommand(array $conn, string $filepath): string - { - $database = escapeshellarg($conn['database'] ?? ':memory:'); - - return "sqlite3 {$database} .dump > {$filepath}"; - } -} - -final class BackupFile -{ - public function __construct( - public readonly string $path, - public readonly string $filename, - public readonly string $timestamp, - public readonly int $size, - public readonly ?string $database = null, - ) {} - - public function exists(): bool - { - return file_exists($this->path); - } - - public function delete(): bool - { - return $this->exists() && unlink($this->path); - } - - public function getFormattedSize(): string - { - $bytes = $this->size; - $units = ['B', 'KB', 'MB', 'GB']; - $bytes = max($bytes, 0); - $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); - $pow = min($pow, count($units) - 1); - $bytes /= (1 << (10 * $pow)); - - return round($bytes, 2) . ' ' . $units[$pow]; - } -} - -final class BackupException extends \RuntimeException -{ - public function __construct(string $message) - { - parent::__construct($message); - } -} diff --git a/plugins/Commands/Configuration/ConfigurationValidator.php b/plugins/Commands/Configuration/ConfigurationValidator.php deleted file mode 100644 index 644e6b7..0000000 --- a/plugins/Commands/Configuration/ConfigurationValidator.php +++ /dev/null @@ -1,76 +0,0 @@ - $conn) { - self::validateConnection((string) $name, $conn); - } - } - - private static function validateConnection(string $name, mixed $conn): void - { - if (!is_array($conn)) { - throw ConfigurationException::invalidStructure( - "Connection [$name] must be an array" - ); - } - - foreach (self::REQUIRED_CONN_KEYS as $key) { - if (!isset($conn[$key])) { - throw ConfigurationException::missingConnection($key); - } - } - - if (!in_array($conn['driver'], self::VALID_DRIVERS, true)) { - throw ConfigurationException::invalidDriver($conn['driver']); - } - } - - private static function validatePaths(array $config): void - { - if (empty($config['paths'] ?? [])) { - throw ConfigurationException::emptyMigrationPaths(); - } - - if (!is_array($config['paths'])) { - throw ConfigurationException::invalidStructure( - 'Migration paths must be an array' - ); - } - } -} diff --git a/plugins/Commands/Configuration/EnvironmentConfigurationLoader.php b/plugins/Commands/Configuration/EnvironmentConfigurationLoader.php deleted file mode 100644 index e14a01f..0000000 --- a/plugins/Commands/Configuration/EnvironmentConfigurationLoader.php +++ /dev/null @@ -1,57 +0,0 @@ -infrastructure->isDeploymentLocked(self::LOCK_KEY)) { - $holder = $this->infrastructure->getDeploymentLockHolder(self::LOCK_KEY); - throw DeploymentLockedException::alreadyLocked(self::LOCK_KEY, $holder ?? 'unknown'); - } - - // Clean up any expired locks - $this->infrastructure->cleanupExpiredDeploymentLocks(); - - // Try to acquire the lock - $holder = $this->getHolderIdentity(); - $expiresAt = $this->getExpirationTime(); - - try { - $this->infrastructure->createDeploymentLock(self::LOCK_KEY, $holder, $expiresAt); - $this->locked = true; - } catch (\Exception) { - throw DeploymentLockedException::acquireFailed(self::LOCK_KEY); - } - } - - public function releaseLock(): void - { - if (!$this->locked) { - return; - } - - try { - $this->infrastructure->deleteDeploymentLock(self::LOCK_KEY); - $this->locked = false; - } catch (\Exception) { - // Log but don't fail - lock will expire anyway - } - } - - public function isLocked(): bool - { - try { - return $this->infrastructure->isDeploymentLocked(self::LOCK_KEY); - } catch (\Exception) { - return false; // Table might not exist yet - } - } - - private function getHolderIdentity(): string - { - $host = gethostname(); - $pid = getmypid(); - $user = get_current_user(); - - return "{$user}@{$host}:{$pid}"; - } - - private function getExpirationTime(): string - { - $timestamp = time() + self::LOCK_TIMEOUT_SECONDS; - return date('Y-m-d H:i:s', $timestamp); - } - - public function __destruct() - { - $this->releaseLock(); - } -} diff --git a/plugins/Commands/Deployment/DeploymentLockedException.php b/plugins/Commands/Deployment/DeploymentLockedException.php deleted file mode 100644 index 4eae2c1..0000000 --- a/plugins/Commands/Deployment/DeploymentLockedException.php +++ /dev/null @@ -1,33 +0,0 @@ -getMessage()}", - context: 'file.load_error', - previous: $e - ); - } - - public static function invalidStructure(string $detail): self - { - return new self( - "Invalid configuration structure: {$detail}", - context: 'structure' - ); - } -} diff --git a/plugins/Commands/Exceptions/ServiceException.php b/plugins/Commands/Exceptions/ServiceException.php deleted file mode 100644 index 53c2b33..0000000 --- a/plugins/Commands/Exceptions/ServiceException.php +++ /dev/null @@ -1,36 +0,0 @@ -factory = CliCommandFactory::fromConfig($config); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to initialize LetMigrate: {$e->getMessage()}" - ); - } - } - - /** - * Get the CliCommandFactory (creates it if needed with null config). - */ - private function factory(): CliCommandFactory - { - if ($this->factory === null) { - $this->factory = CliCommandFactory::fromConfig(null); - } - return $this->factory; - } - - /** - * Get all available migration commands. - * - * @return array Command class name -> instance - */ - public function getMigrationCommands(): array - { - try { - return $this->factory()->all(); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to load migration commands: {$e->getMessage()}" - ); - } - } - - /** - * Get only the core migrate:* commands. - */ - public function getMigrateCommands(): array - { - try { - return $this->factory()->migrate(); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to load migrate commands: {$e->getMessage()}" - ); - } - } - - /** - * Get the underlying configuration (for inspection). - */ - public function getConfig(): ?array - { - return $this->factory()->config(); - } -} diff --git a/plugins/Commands/Infrastructure/Gateways/ShellGateway.php b/plugins/Commands/Infrastructure/Gateways/ShellGateway.php deleted file mode 100644 index 41413de..0000000 --- a/plugins/Commands/Infrastructure/Gateways/ShellGateway.php +++ /dev/null @@ -1,137 +0,0 @@ -getMessage()}", - ); - } - } - - /** - * Run a git command. - */ - public function git(string $args): ShellResult - { - return $this->execute("git {$args}", context: 'git'); - } - - /** - * Check if git is available. - */ - public function gitAvailable(): bool - { - try { - $result = Shell::run('git --version'); - return $result->ok(); - } catch (\Throwable) { - return false; - } - } - - /** - * Check if composer is available. - */ - public function composerAvailable(): bool - { - try { - $result = Shell::run('composer --version'); - return $result->ok(); - } catch (\Throwable) { - return false; - } - } - - /** - * Check if a directory exists. - */ - public function directoryExists(string $path): bool - { - return is_dir($path); - } - - /** - * Check if a file exists. - */ - public function fileExists(string $path): bool - { - return is_file($path); - } - - /** - * Ensure a directory exists (create if missing). - * - * @throws ServiceException - */ - public function ensureDirectory(string $path): void - { - if ($this->directoryExists($path)) { - return; - } - - if (!@mkdir($path, 0755, true)) { - throw ServiceException::moduleAddFailed("Could not create directory: {$path}"); - } - } - - /** - * Write content to a file. - * - * @throws ServiceException - */ - public function writeFile(string $path, string $content): void - { - $dir = dirname($path); - if (!$this->directoryExists($dir)) { - $this->ensureDirectory($dir); - } - - if (file_put_contents($path, $content) === false) { - throw ServiceException::moduleAddFailed("Could not write file: {$path}"); - } - } - - /** - * Read file contents. - * - * @throws ServiceException - */ - public function readFile(string $path): string - { - if (!$this->fileExists($path)) { - throw ServiceException::moduleAddFailed("File not found: {$path}"); - } - - $content = file_get_contents($path); - if ($content === false) { - throw ServiceException::moduleAddFailed("Could not read file: {$path}"); - } - - return $content; - } -} diff --git a/plugins/Commands/Infrastructure/Http/Commands/ModuleAddCommand.php b/plugins/Commands/Infrastructure/Http/Commands/ModuleAddCommand.php deleted file mode 100644 index 19b7a47..0000000 --- a/plugins/Commands/Infrastructure/Http/Commands/ModuleAddCommand.php +++ /dev/null @@ -1,95 +0,0 @@ -name = 'module:add'; - $this->description = 'Add a git submodule and register it as a Composer path package'; - $this->help = <<<'HELP' -Clones the module as a git submodule under modules/, bootstraps its -src/ directory and composer.json if absent, then patches the root composer.json -so Composer can resolve the package via a path repository. - -Example: - php cli module:add payments git@github.com:acme/payments.git acme - php cli module:add payments git@github.com:acme/payments.git acme --offline -HELP; - - $this->addArgument('name', 'Module name in kebab-case (e.g. user-auth)', required: true); - $this->addArgument('git-url', 'Git repository URL (SSH or HTTPS)', required: true); - $this->addArgument('org', 'Composer vendor / GitHub org (e.g. acme)', required: true); - $this->addOption('offline', 'o', 'Install without network (COMPOSER_DISABLE_NETWORK=1)'); - } - - protected function handle(): int - { - try { - $request = ModuleAddRequest::fromInput($this); - - // Summary table - $this->section('Module Add Plan'); - $this->table() - ->headers(['Field', 'Value']) - ->style('compact') - ->rows([ - ['Module name', $request->name], - ['Git URL', $request->gitUrl], - ['Path', $request->getModulePath()], - ['Package', $request->getPackageName()], - ['Namespace', $request->getNamespace()], - ['Composer mode', $request->offline ? 'offline (no network)' : 'online'], - ]) - ->render(); - - if (!$this->confirm('Proceed with adding this module?')) { - $this->muted('Aborted.'); - return self::SUCCESS; - } - - $this->newLine(); - - // Call service - $response = $this->service->addModule($request); - - if ($response->success) { - $this->alertSuccess('Module Added Successfully', [ - "Path: {$response->modulePath}", - "Package: {$response->packageName}", - "Namespace: {$response->namespace}", - ]); - return self::SUCCESS; - } else { - $this->alertError('Failed to Add Module', [$response->error ?? 'Unknown error']); - return self::FAILURE; - } - } catch (ServiceException $e) { - $this->alertError('Service Error', [$e->getMessage()]); - return self::FAILURE; - } catch (\Throwable $e) { - $this->alertError('Unexpected Error', [$e->getMessage()]); - return self::FAILURE; - } - } -} diff --git a/plugins/Commands/Infrastructure/Http/Commands/ModuleRemoveCommand.php b/plugins/Commands/Infrastructure/Http/Commands/ModuleRemoveCommand.php deleted file mode 100644 index 7e7b622..0000000 --- a/plugins/Commands/Infrastructure/Http/Commands/ModuleRemoveCommand.php +++ /dev/null @@ -1,91 +0,0 @@ -name = 'module:remove'; - $this->description = 'Completely remove a git submodule and its Composer registration'; - $this->help = <<<'HELP' -Removes a git submodule and cleans up: - • .gitmodules entry - • .git/config entry - • modules/ directory - • root composer.json references - -Example: - php cli module:remove payments - php cli module:remove payments --force -HELP; - - $this->addArgument('name', 'Module name in kebab-case', required: true); - $this->addOption('force', 'f', 'Skip confirmations'); - } - - protected function handle(): int - { - try { - $request = ModuleRemoveRequest::fromInput($this); - - if (!$request->force) { - $this->section('Module Removal Plan'); - $this->table() - ->headers(['Action']) - ->rows([ - ['Remove: ' . $request->getModulePath()], - ['Clean: .gitmodules'], - ['Clean: .git/config'], - ['Clean: composer.json'], - ]) - ->render(); - - if (!$this->confirm('Proceed with removing this module? This cannot be undone.')) { - $this->muted('Aborted.'); - return self::SUCCESS; - } - - $this->newLine(); - } - - // Call service - $response = $this->service->removeModule($request); - - if ($response->success) { - $this->alertSuccess('Module Removed Successfully', [ - "Module: {$response->moduleName}", - ]); - return self::SUCCESS; - } else { - $this->alertError('Failed to Remove Module', [$response->error ?? 'Unknown error']); - return self::FAILURE; - } - } catch (ServiceException $e) { - $this->alertError('Service Error', [$e->getMessage()]); - return self::FAILURE; - } catch (\Throwable $e) { - $this->alertError('Unexpected Error', [$e->getMessage()]); - return self::FAILURE; - } - } -} diff --git a/plugins/Commands/Infrastructure/Http/Commands/RouteListCommand.php b/plugins/Commands/Infrastructure/Http/Commands/RouteListCommand.php deleted file mode 100644 index 775362e..0000000 --- a/plugins/Commands/Infrastructure/Http/Commands/RouteListCommand.php +++ /dev/null @@ -1,210 +0,0 @@ -name = 'route:list'; - $this->description = 'List all routes compiled into the route manifest'; - $this->help = <<<'HELP' -Reads the compiled route manifest (var/cache/manifests/route-manifest.php) and -prints every registered route with its handler, owning module/scope, filters and -per-route module requires. - -Project routes resolve under the synthetic "__project__" scope; a project route -that overrides a plugin route shows the overridden module. - -Options: - --method=VERB Only routes matching this HTTP method (case-insensitive) - --path=PREFIX Only routes whose path starts with PREFIX - --json Emit the raw manifest as JSON (for scripting) - -Examples: - hkm route:list - hkm route:list --method=POST - hkm route:list --path=/api/invoices - hkm route:list --json -HELP; - - $this->addOption('method', 'm', 'Filter by HTTP method', acceptsValue: true); - $this->addOption('path', 'p', 'Filter by path prefix', acceptsValue: true); - $this->addOption('json', 'j', 'Output the manifest as JSON'); - } - - protected function handle(): int - { - $manifest = $this->loadManifest(); - - if ($manifest === null) { - $this->alertWarning('Route manifest not found', [ - 'Expected: ' . Paths::cache('manifests/route-manifest.php'), - 'Boot the app once (any entry point) to compile it, then retry.', - ]); - return self::FAILURE; - } - - $methodFilter = strtoupper(trim((string) $this->option('method', ''))); - $pathFilter = (string) $this->option('path', ''); - - $rows = $this->filterRoutes($manifest, $methodFilter, $pathFilter); - - if ($this->hasOption('json')) { - echo json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; - return self::SUCCESS; - } - - if ($rows === []) { - $this->info('No routes match the given filters.'); - return self::SUCCESS; - } - - $this->section('Registered routes'); - - $tableRows = []; - foreach ($rows as $key => $entry) { - [$method, $path] = array_pad(explode(' ', $key, 2), 2, ''); - - $tableRows[] = [ - $this->colorMethod($method), - $path, - (string) ($entry['handler'] ?? '—'), - $this->scopeLabel($entry), - $this->listLabel($entry['filters'] ?? []), - $this->listLabel($entry['requires'] ?? []), - ]; - } - - $this->table() - ->headers(['Method', 'Path', 'Handler', 'Scope', 'Filters', 'Requires']) - ->rows($tableRows) - ->render(); - - $this->muted(' ' . count($rows) . ' route' . (count($rows) === 1 ? '' : 's') . ' shown'); - - return self::SUCCESS; - } - - /** - * @return array>|null - */ - private function loadManifest(): ?array - { - $path = Paths::cache('manifests/route-manifest.php'); - - if (!is_file($path)) { - return null; - } - - /** @var mixed $manifest */ - $manifest = require $path; - - return is_array($manifest) ? $manifest : []; - } - - /** - * @param array> $manifest - * @return array> - */ - private function filterRoutes(array $manifest, string $methodFilter, string $pathFilter): array - { - $filtered = []; - - foreach ($manifest as $key => $entry) { - [$method, $path] = array_pad(explode(' ', $key, 2), 2, ''); - - if ($methodFilter !== '' && strtoupper($method) !== $methodFilter) { - continue; - } - if ($pathFilter !== '' && !str_starts_with($path, $pathFilter)) { - continue; - } - - $filtered[$key] = $entry; - } - - // Deterministic order: path, then method. - uksort($filtered, static function (string $a, string $b): int { - [$ma, $pa] = array_pad(explode(' ', $a, 2), 2, ''); - [$mb, $pb] = array_pad(explode(' ', $b, 2), 2, ''); - return [$pa, $ma] <=> [$pb, $mb]; - }); - - return $filtered; - } - - private function colorMethod(string $method): string - { - $method = strtoupper($method); - - return match ($method) { - 'GET' => Colors::wrap($method, Colors::GREEN), - 'POST' => Colors::wrap($method, Colors::BLUE), - 'PUT', - 'PATCH' => Colors::wrap($method, Colors::YELLOW), - 'DELETE' => Colors::wrap($method, Colors::RED), - default => Colors::muted($method), - }; - } - - /** - * @param array $entry - */ - private function scopeLabel(array $entry): string - { - $solves = (string) ($entry['solves'] ?? ''); - - if ($solves === '__project__') { - $overrides = $entry['overrides'] ?? null; - $label = Colors::wrap('project', Colors::CYAN); - return $overrides !== null - ? $label . Colors::muted(' (overrides ' . $this->shortClass((string) $overrides) . ')') - : $label; - } - - return $solves !== '' ? $solves : Colors::muted('—'); - } - - /** - * @param mixed $list - */ - private function listLabel(mixed $list): string - { - if (!is_array($list) || $list === []) { - return Colors::muted('—'); - } - - return implode(', ', array_map(static fn($v): string => (string) $v, $list)); - } - - private function shortClass(string $class): string - { - $parts = explode('\\', $class); - return end($parts) ?: $class; - } -} diff --git a/plugins/Commands/Infrastructure/Persistence/ApprovalRepository.php b/plugins/Commands/Infrastructure/Persistence/ApprovalRepository.php deleted file mode 100644 index e4dbe0a..0000000 --- a/plugins/Commands/Infrastructure/Persistence/ApprovalRepository.php +++ /dev/null @@ -1,158 +0,0 @@ -db->execute( - 'INSERT INTO migration_approvals (id, migrations, requester, status, created_at) VALUES (?, ?, ?, ?, NOW())', - [ - $approvalId, - json_encode($migrations), - $requester, - 'pending', - ] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to create approval request: {$e->getMessage()}" - ); - } - } - - /** - * Get a specific approval request. - */ - public function getApprovalRequest(string $approvalId): ?array - { - try { - $record = $this->db->queryOne( - 'SELECT * FROM migration_approvals WHERE id = ?', - [$approvalId] - ); - - if ($record && is_string($record['migrations'])) { - $record['migrations'] = json_decode($record['migrations'], true); - } - - return $record; - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to get approval request: {$e->getMessage()}" - ); - } - } - - /** - * Approve a migration request. - * - * @throws ServiceException - */ - public function approve( - string $approvalId, - string $approver, - ?string $notes = null, - ): void { - try { - $this->db->execute( - 'UPDATE migration_approvals SET status = ?, approver = ?, approved_at = NOW(), notes = ? WHERE id = ?', - ['approved', $approver, $notes, $approvalId] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to approve migration: {$e->getMessage()}" - ); - } - } - - /** - * Reject a migration request. - * - * @throws ServiceException - */ - public function reject( - string $approvalId, - string $rejector, - string $reason, - ): void { - try { - $this->db->execute( - 'UPDATE migration_approvals SET status = ?, approver = ?, approved_at = NOW(), notes = ? WHERE id = ?', - ['rejected', $rejector, $reason, $approvalId] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to reject migration: {$e->getMessage()}" - ); - } - } - - /** - * Get all pending approval requests. - */ - public function getPendingApprovals(): array - { - try { - $records = $this->db->query( - 'SELECT * FROM migration_approvals WHERE status = ? ORDER BY created_at DESC', - ['pending'] - ); - - return array_map(function ($record) { - if (is_string($record['migrations'])) { - $record['migrations'] = json_decode($record['migrations'], true); - } - return $record; - }, $records); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to get pending approvals: {$e->getMessage()}" - ); - } - } - - /** - * Check if there's a pending approval that hasn't timed out. - */ - public function hasPendingApproval(int $timeoutSeconds = 3600): bool - { - try { - $approval = $this->db->queryOne( - 'SELECT * FROM migration_approvals WHERE status = ? AND created_at > DATE_SUB(NOW(), INTERVAL ? SECOND)', - ['pending', $timeoutSeconds] - ); - - return $approval !== null; - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to check pending approvals: {$e->getMessage()}" - ); - } - } -} diff --git a/plugins/Commands/Infrastructure/Persistence/BackupRepository.php b/plugins/Commands/Infrastructure/Persistence/BackupRepository.php deleted file mode 100644 index 1a597f6..0000000 --- a/plugins/Commands/Infrastructure/Persistence/BackupRepository.php +++ /dev/null @@ -1,94 +0,0 @@ -db->execute( - 'INSERT INTO backups (database_name, backup_path, filename, file_size, created_at) VALUES (?, ?, ?, ?, NOW())', - [$database, $backupPath, $filename, $fileSizeBytes] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to record backup: {$e->getMessage()}" - ); - } - } - - /** - * List recent backups for a database. - */ - public function listBackups(string $database, int $limit = 10): array - { - try { - return $this->db->query( - 'SELECT * FROM backups WHERE database_name = ? ORDER BY created_at DESC LIMIT ?', - [$database, $limit] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to list backups: {$e->getMessage()}" - ); - } - } - - /** - * Get a specific backup by filename. - */ - public function getBackup(string $filename): ?array - { - try { - return $this->db->queryOne( - 'SELECT * FROM backups WHERE filename = ?', - [$filename] - ); - } catch (\Throwable $e) { - return null; - } - } - - /** - * Delete old backup records (older than 30 days). - */ - public function deleteOldBackupRecords(int $daysOld = 30): int - { - try { - $this->db->execute( - 'DELETE FROM backups WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)', - [$daysOld] - ); - return 0; // Success - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to clean up backup records: {$e->getMessage()}" - ); - } - } -} diff --git a/plugins/Commands/Infrastructure/Persistence/CommandAuditLogRepository.php b/plugins/Commands/Infrastructure/Persistence/CommandAuditLogRepository.php deleted file mode 100644 index fd21c16..0000000 --- a/plugins/Commands/Infrastructure/Persistence/CommandAuditLogRepository.php +++ /dev/null @@ -1,138 +0,0 @@ -db->execute( - 'INSERT INTO command_audit_logs (command, user, hostname, pid, arguments, executed_at) VALUES (?, ?, ?, ?, ?, NOW())', - [ - $command, - $user, - $hostname, - $pid, - json_encode($arguments), - ] - ); - return $this->db->lastInsertId(); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to log command: {$e->getMessage()}" - ); - } - } - - /** - * Update command execution end log (exit code, duration, error message). - * - * @throws ServiceException - */ - public function logEnd( - string $logId, - int $exitCode, - int $durationMs, - ?string $errorMessage = null, - ): void { - try { - $this->db->execute( - 'UPDATE command_audit_logs SET exit_code = ?, duration_ms = ?, error_message = ? WHERE id = ?', - [$exitCode, $durationMs, $errorMessage, $logId] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to update command log: {$e->getMessage()}" - ); - } - } - - /** - * Log a migration operation. - * - * @throws ServiceException - */ - public function logMigration( - string $logId, - string $migrationName, - string $direction, - bool $success, - ?string $errorMessage = null, - ): void { - try { - $this->db->execute( - 'INSERT INTO command_audit_logs (parent_id, migration_name, direction, success, error_message) VALUES (?, ?, ?, ?, ?)', - [$logId, $migrationName, $direction, $success ? 1 : 0, $errorMessage] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to log migration: {$e->getMessage()}" - ); - } - } - - /** - * Log a destructive operation. - * - * @throws ServiceException - */ - public function logDestructiveOperation( - string $logId, - string $operationName, - string $details, - ): void { - try { - $this->db->execute( - 'INSERT INTO command_audit_logs (parent_id, destructive_op, operation_details) VALUES (?, ?, ?)', - [$logId, $operationName, $details] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to log destructive operation: {$e->getMessage()}" - ); - } - } - - /** - * Query recent logs. - */ - public function getRecentLogs(int $limit = 20): array - { - try { - return $this->db->query( - 'SELECT * FROM command_audit_logs WHERE parent_id IS NULL ORDER BY executed_at DESC LIMIT ?', - [$limit] - ); - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to query audit logs: {$e->getMessage()}" - ); - } - } -} diff --git a/plugins/Commands/Infrastructure/Persistence/DeploymentLockRepository.php b/plugins/Commands/Infrastructure/Persistence/DeploymentLockRepository.php deleted file mode 100644 index 48bdff8..0000000 --- a/plugins/Commands/Infrastructure/Persistence/DeploymentLockRepository.php +++ /dev/null @@ -1,102 +0,0 @@ -db->queryOne( - 'SELECT * FROM deployment_locks WHERE lock_key = ? AND expires_at > NOW()', - [$lockKey] - ); - return $lock !== null; - } catch (\Throwable $e) { - throw ServiceException::lockAcquisitionFailed( - "Failed to check lock status: {$e->getMessage()}" - ); - } - } - - /** - * Get the current lock holder identity (for error messages). - */ - public function getLockHolder(string $lockKey): ?string - { - try { - $lock = $this->db->queryOne( - 'SELECT holder FROM deployment_locks WHERE lock_key = ? AND expires_at > NOW()', - [$lockKey] - ); - return $lock['holder'] ?? null; - } catch (\Throwable $e) { - return null; - } - } - - /** - * Create a new deployment lock. - * - * @throws ServiceException - */ - public function createLock(string $lockKey, string $holder, string $expiresAt): void - { - try { - $this->db->execute( - 'INSERT INTO deployment_locks (lock_key, holder, expires_at) VALUES (?, ?, ?)', - [$lockKey, $holder, $expiresAt] - ); - } catch (\Throwable $e) { - throw ServiceException::lockAcquisitionFailed($e->getMessage()); - } - } - - /** - * Delete a deployment lock. - */ - public function deleteLock(string $lockKey): void - { - try { - $this->db->execute( - 'DELETE FROM deployment_locks WHERE lock_key = ?', - [$lockKey] - ); - } catch (\Throwable $e) { - throw ServiceException::lockAcquisitionFailed($e->getMessage()); - } - } - - /** - * Clean up expired locks. - */ - public function cleanupExpiredLocks(): void - { - try { - $this->db->execute( - 'DELETE FROM deployment_locks WHERE expires_at <= NOW()' - ); - } catch (\Throwable $e) { - // Ignore cleanup errors, just log them - error_log("Failed to cleanup deployment locks: {$e->getMessage()}"); - } - } -} diff --git a/plugins/Commands/Infrastructure/Persistence/MigrationRepository.php b/plugins/Commands/Infrastructure/Persistence/MigrationRepository.php deleted file mode 100644 index 9f839d2..0000000 --- a/plugins/Commands/Infrastructure/Persistence/MigrationRepository.php +++ /dev/null @@ -1,199 +0,0 @@ -loadFromFile($configPath); - } - - return EnvironmentConfigurationLoader::load($this->projectRoot); - } catch (ConfigurationException $e) { - throw ServiceException::migrationFailed( - "Failed to load configuration: {$e->getMessage()}" - ); - } - } - - /** - * Load configuration from a specific file path. - * - * @throws ConfigurationException - */ - private function loadFromFile(string $path): array - { - $fullPath = str_starts_with($path, '/') ? $path : $this->projectRoot . '/' . $path; - - if (!is_file($fullPath)) { - throw ConfigurationException::fileNotFound($fullPath); - } - - try { - $config = require $fullPath; - return ConfigurationValidator::validate($config); - } catch (\Throwable $e) { - throw ConfigurationException::loadFailed($fullPath, $e); - } - } - - /** - * Run all pending migrations via LetMigrate. - * - * @throws ServiceException - */ - public function runPending(array $config): array - { - try { - $this->letMigrate->initializeWithConfig($config); - $commands = $this->letMigrate->getMigrateCommands(); - - // Find migrate:run command and execute it - // Note: This is a simplified interface; actual implementation - // depends on LetMigrate's command structure - return []; - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to run migrations: {$e->getMessage()}" - ); - } - } - - /** - * Rollback the last N migration batches. - * - * @throws ServiceException - */ - public function rollback(array $config, int $steps = 1): array - { - try { - $this->letMigrate->initializeWithConfig($config); - // Implementation depends on LetMigrate's actual API - return []; - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to rollback migrations: {$e->getMessage()}" - ); - } - } - - /** - * Get the current migration status (applied vs pending). - * - * @throws ServiceException - */ - public function getStatus(array $config): array - { - try { - $this->letMigrate->initializeWithConfig($config); - // Implementation depends on LetMigrate's actual API - return [ - 'applied' => [], - 'pending' => [], - ]; - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to get migration status: {$e->getMessage()}" - ); - } - } - - /** - * Reset all migrations (roll back everything). - * - * @throws ServiceException - */ - public function reset(array $config): array - { - try { - $this->letMigrate->initializeWithConfig($config); - // Implementation: get all applied migrations and rollback all - return []; - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to reset migrations: {$e->getMessage()}" - ); - } - } - - /** - * Refresh migrations (reset + re-run all). - * - * @throws ServiceException - */ - public function refresh(array $config): array - { - try { - // First reset - $this->reset($config); - - // Then re-run all - return $this->runPending($config); - } catch (ServiceException $e) { - throw $e; - } catch (\Throwable $e) { - throw ServiceException::migrationFailed( - "Failed to refresh migrations: {$e->getMessage()}" - ); - } - } - - /** - * Check if database is accessible (health check). - * Returns true if connection is OK, false otherwise. - */ - public function isDatabaseAccessible(array $config): bool - { - try { - // Load configuration and test connection - $this->letMigrate->initializeWithConfig($config); - // If we got here, config is valid - return true; - } catch (\Throwable) { - return false; - } - } - - /** - * Check if migration tracking table exists. - */ - public function doesTrackingTableExist(array $config): bool - { - try { - $table = $config['tracking_table'] ?? 'let_migrations'; - // Try to query INFORMATION_SCHEMA (MySQL/MariaDB compatible) - // This is a basic check; actual implementation depends on database type - return true; - } catch (\Throwable) { - return false; - } - } -} diff --git a/plugins/Commands/Infrastructure/Persistence/ModuleRepository.php b/plugins/Commands/Infrastructure/Persistence/ModuleRepository.php deleted file mode 100644 index 17e83e0..0000000 --- a/plugins/Commands/Infrastructure/Persistence/ModuleRepository.php +++ /dev/null @@ -1,241 +0,0 @@ -projectRoot . '/' . $request->getModulePath(); - - // 1. Check module doesn't already exist - if ($this->moduleExists($modulePath)) { - throw ServiceException::moduleAddFailed( - "Module already exists at {$request->getModulePath()}" - ); - } - - try { - // 2. Clone as submodule - $this->shell->git( - "submodule add {$request->gitUrl} {$request->getModulePath()}" - ); - - // 3. Initialize submodule - $this->shell->git('submodule update --init --recursive'); - - // 4. Scaffold src/ directory if missing - $srcPath = $modulePath . '/src'; - if (!$this->shell->directoryExists($srcPath)) { - $this->shell->ensureDirectory($srcPath); - } - - // 5. Create composer.json if missing - $composerPath = $modulePath . '/composer.json'; - if (!$this->shell->fileExists($composerPath)) { - $this->createComposerJson($composerPath, $request); - } - - // 6. Update root composer.json - $this->updateRootComposerJson($request); - - // 7. Run composer update - $this->runComposerUpdate($request->offline); - - return ModuleAddResponse::success( - $request->getModulePath(), - $request->getPackageName(), - $request->getNamespace(), - ); - } catch (ServiceException $e) { - throw $e; - } catch (\Throwable $e) { - throw ServiceException::moduleAddFailed($e->getMessage()); - } - } - - /** - * Remove a git submodule and clean up Composer. - * - * @throws ServiceException - */ - public function remove(ModuleRemoveRequest $request): ModuleRemoveResponse - { - $modulePath = $this->projectRoot . '/' . $request->getModulePath(); - - // 1. Check module exists - if (!$this->moduleExists($modulePath)) { - throw ServiceException::moduleRemoveFailed( - "Module not found at {$request->getModulePath()}" - ); - } - - try { - // 2. Remove from .gitmodules - $this->shell->git("config --file=.gitmodules --remove-section submodule.{$request->name}"); - - // 3. Remove from .git/config - $this->shell->git("config --remove-section submodule.{$request->name}"); - - // 4. Remove the submodule directory - $this->shell->git("rm -f {$request->getModulePath()}"); - - // 5. Clean .gitmodules if empty - if (!$this->gitmodulesHasContent()) { - $this->shell->execute("rm -f {$this->projectRoot}/.gitmodules"); - } - - // 6. Remove from root composer.json - $this->removeFromRootComposerJson($request->name); - - // 7. Run composer update - $this->shell->execute('composer update', context: 'composer'); - - return ModuleRemoveResponse::success($request->name); - } catch (ServiceException $e) { - throw $e; - } catch (\Throwable $e) { - throw ServiceException::moduleRemoveFailed($e->getMessage()); - } - } - - /** - * Check if a module exists. - */ - private function moduleExists(string $modulePath): bool - { - return $this->shell->directoryExists($modulePath); - } - - /** - * Create a basic composer.json for the module. - */ - private function createComposerJson(string $path, ModuleAddRequest $request): void - { - $json = [ - 'name' => $request->getPackageName(), - 'type' => 'library', - 'description' => "Module: {$request->name}", - 'autoload' => [ - 'psr-4' => [ - $request->getNamespace() => 'src/', - ], - ], - ]; - - $content = json_encode($json, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n"; - $this->shell->writeFile($path, $content); - } - - /** - * Add the module to root composer.json. - */ - private function updateRootComposerJson(ModuleAddRequest $request): void - { - $composerPath = $this->projectRoot . '/composer.json'; - $content = $this->shell->readFile($composerPath); - $composer = json_decode($content, true); - - // Add path repository - if (!isset($composer['repositories'])) { - $composer['repositories'] = []; - } - - $composer['repositories'][] = [ - 'type' => 'path', - 'url' => $request->getModulePath(), - ]; - - // Add require entry - if (!isset($composer['require'])) { - $composer['require'] = []; - } - - $composer['require'][$request->getPackageName()] = '*'; - - $updated = json_encode($composer, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n"; - $this->shell->writeFile($composerPath, $updated); - } - - /** - * Remove module from root composer.json. - */ - private function removeFromRootComposerJson(string $moduleName): void - { - $composerPath = $this->projectRoot . '/composer.json'; - $content = $this->shell->readFile($composerPath); - $composer = json_decode($content, true); - - // Remove repository - if (isset($composer['repositories'])) { - $composer['repositories'] = array_filter( - $composer['repositories'], - fn($repo) => !isset($repo['url']) || !str_contains($repo['url'], "modules/{$moduleName}") - ); - } - - // Remove require - if (isset($composer['require'])) { - foreach ($composer['require'] as $pkg => $ver) { - if (str_contains($pkg, $moduleName)) { - unset($composer['require'][$pkg]); - } - } - } - - $updated = json_encode($composer, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . "\n"; - $this->shell->writeFile($composerPath, $updated); - } - - /** - * Check if .gitmodules has content. - */ - private function gitmodulesHasContent(): bool - { - $path = $this->projectRoot . '/.gitmodules'; - if (!$this->shell->fileExists($path)) { - return false; - } - - $content = $this->shell->readFile($path); - return !empty(trim($content)); - } - - /** - * Run composer update. - */ - private function runComposerUpdate(bool $offline): void - { - $cmd = $offline - ? 'COMPOSER_DISABLE_NETWORK=1 composer update' - : 'composer update'; - - $result = $this->shell->execute($cmd, context: 'composer'); - if (!$result->ok()) { - throw new ServiceException("Composer update failed: {$result->output()}"); - } - } -} diff --git a/plugins/Commands/Logging/CommandExecutionLogger.php b/plugins/Commands/Logging/CommandExecutionLogger.php deleted file mode 100644 index 6d1d518..0000000 --- a/plugins/Commands/Logging/CommandExecutionLogger.php +++ /dev/null @@ -1,92 +0,0 @@ -startTime = microtime(true); - } - - public function logStart(string $command, array $argv): void - { - $this->commandName = $command; - - $this->logger->log(LogLevel::INFO, 'CLI command started', [ - 'command' => $command, - 'argv' => $this->sanitizeArgv($argv), - 'user' => $this->getCurrentUser(), - 'pid' => getmypid(), - 'hostname' => gethostname(), - 'timestamp' => date('c'), - ]); - } - - public function logEnd(int $exitCode, ?string $error = null): void - { - $duration = microtime(true) - $this->startTime; - $level = $exitCode === 0 ? LogLevel::INFO : LogLevel::WARNING; - - $this->logger->log($level, 'CLI command completed', [ - 'command' => $this->commandName, - 'exit_code' => $exitCode, - 'duration_ms' => (int) ($duration * 1000), - 'error' => $error, - 'user' => $this->getCurrentUser(), - 'pid' => getmypid(), - ]); - } - - public function logMigration(string $migration, string $direction, bool $success, float $duration, ?string $error = null): void - { - $level = $success ? LogLevel::INFO : LogLevel::ERROR; - - $this->logger->log($level, 'Migration execution', [ - 'migration' => $migration, - 'direction' => $direction, - 'success' => $success, - 'duration_ms' => (int) ($duration * 1000), - 'error' => $error, - 'user' => $this->getCurrentUser(), - 'pid' => getmypid(), - ]); - } - - public function logDestructiveOperation(string $operation, array $details): void - { - $this->logger->log(LogLevel::WARNING, 'Destructive operation executed', [ - 'operation' => $operation, - 'user' => $this->getCurrentUser(), - 'details' => $details, - 'timestamp' => date('c'), - ]); - } - - private function sanitizeArgv(array $argv): array - { - return array_map(function ($arg) { - $sensitive = ['password', 'secret', 'token', 'key', 'api-key', 'aws-']; - foreach ($sensitive as $keyword) { - if (str_contains(strtolower((string) $arg), $keyword)) { - return '***REDACTED***'; - } - } - return $arg; - }, $argv); - } - - private function getCurrentUser(): string - { - return get_current_user() ?: 'unknown'; - } -} diff --git a/plugins/Commands/Provider.php b/plugins/Commands/Provider.php deleted file mode 100644 index 9f217ed..0000000 --- a/plugins/Commands/Provider.php +++ /dev/null @@ -1,309 +0,0 @@ -singleton(DeploymentLockRepository::class, fn($c) => - new DeploymentLockRepository( - $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort::class) - ) - ); - $container->singleton(CommandAuditLogRepository::class, fn($c) => - new CommandAuditLogRepository( - $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort::class) - ) - ); - $container->singleton(BackupRepository::class, fn($c) => - new BackupRepository( - $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort::class) - ) - ); - $container->singleton(ApprovalRepository::class, fn($c) => - new ApprovalRepository( - $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort::class) - ) - ); - $container->singleton(MigrationRepository::class, fn($c) => - new MigrationRepository( - $c->make(LetMigrateGateway::class), - $projectRoot - ) - ); - $container->singleton(ModuleRepository::class, fn($c) => - new ModuleRepository( - $c->make(ShellGateway::class), - $projectRoot - ) - ); - - // Register single infrastructure service that aggregates all repositories - $container->singleton(CommandsInfrastructureService::class, fn($c) => - new CommandsInfrastructureService( - $c->make(DeploymentLockRepository::class), - $c->make(CommandAuditLogRepository::class), - $c->make(BackupRepository::class), - $c->make(ApprovalRepository::class), - $c->make(MigrationRepository::class), - $c->make(ModuleRepository::class), - ) - ); - - // Register enterprise feature classes (all use the single infrastructure service!) - // - // NOTE: this used to bind Psr\Log\LoggerInterface to a NullLogger — the - // ONLY logger binding in the codebase — so every command-audit line, and - // every line the Database/Tenancy/EventBus components wrote, was silently - // discarded. Resolve the real LoggerPort instead; the Logger plugin - // supplies a file-backed default when the project has not wired one. - $container->singleton(CommandExecutionLogger::class, fn($c) => - new CommandExecutionLogger($c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort::class)) - ); - $container->singleton(DeploymentLockManager::class, fn($c) => - new DeploymentLockManager($c->make(CommandsInfrastructureService::class)) - ); - $container->singleton(BackupManager::class, fn($c) => - new BackupManager() - ); - $container->singleton(MigrationApprovalManager::class, fn($c) => - new MigrationApprovalManager($c->make(CommandsInfrastructureService::class)) - ); - $container->singleton(PreFlightValidator::class, fn($c) => - new PreFlightValidator($c->make(CommandsInfrastructureService::class)) - ); - - // Register gateways - $container->singleton(ShellGateway::class, fn($c) => - new ShellGateway() - ); - $container->singleton(LetMigrateGateway::class, fn($c) => - new LetMigrateGateway() - ); - - // Register public service contracts - $container->bind(ModuleManagementServiceContract::class, fn($c) => - new ModuleManagementService( - $c->make(ModuleRepository::class), - $c->make(CommandExecutionLogger::class), - $c->make(DeploymentLockManager::class), - ) - ); - $container->bind(MigrationServiceContract::class, fn($c) => - new MigrationService( - $c->make(MigrationRepository::class), - $c->make(CommandExecutionLogger::class), - $c->make(DeploymentLockManager::class), - $c->make(BackupManager::class), - $c->make(MigrationApprovalManager::class), - $c->make(PreFlightValidator::class), - ) - ); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // All registration below builds DB-backed services, a scoped container, - // and 25+ factory-injected migration command instances. That work is - // pointless (and expensive) on the HTTP/worker path, where boot() still - // runs but the CLI is never invoked. Defer it so it executes ONLY when - // the CLI actually materializes its commands. - $cli->defer(function (CliPipeline $cli): void { - // ── Module Management Commands ──────────────────────────────── - // These commands depend on module-scoped services. Build a scoped - // ModuleContainer (mirroring the OnDemandLoader) so register() wires - // the service graph, then resolve the commands with deps injected. - $scoped = new ModuleContainer($cli->container()); - $scoped->setScope($this->solves()); - $this->register($scoped); - - $cli->command($scoped->makeInScope(ModuleAddCommand::class, $this->solves())); - $cli->command($scoped->makeInScope(ModuleRemoveCommand::class, $this->solves())); - - // Read-only introspection — no DB deps, resolves straight from the manifest. - $cli->command(new RouteListCommand()); - - // ── Migration Commands with Enterprise Safeguards ────────────── - try { - $migrateConfig = $this->loadConfiguration(); - } catch (ConfigurationException $e) { - error_log("Configuration Error: {$e->getMessage()}"); - - // Fail hard in production - if ($this->isProduction()) { - throw new \AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\BootFailureException( - "Cannot boot: {$e->getMessage()}", - previous: $e - ); - } - - // In development, use minimal fallback config - $migrateConfig = $this->getMinimalConfig(); - } - - $migrationFactory = MigrateFactory::fromConfig($migrateConfig); - - // Register all 25+ migration commands. Pass the built instances - // directly so their factory-injected dependencies are preserved - // (re-instantiating via class-string would drop them). - // - // Yield to any command a plugin already claimed (queued at boot, - // before this deferred callback runs). This is why the kernel's - // generic LetMigrate `tenant:*` commands do NOT shadow the Tenancy - // plugin's registry-based equivalents when Tenancy is enabled — and - // they still register normally when it is not. - foreach ($migrationFactory->all() as $commandInstance) { - if ($cli->hasQueued($commandInstance->getName())) { - continue; - } - $cli->command($commandInstance); - } - }); - } - - private function loadConfiguration(): array - { - // Use per-project config via Paths (resolves under project root) - try { - return $this->withPluginMigrationPaths(EnvironmentConfigurationLoader::load()); - } catch (ConfigurationException) { - // Fall back to base config if environment-specific doesn't exist - $baseConfigPath = Paths::config('let-migrate.php'); - - if (!is_file($baseConfigPath)) { - throw ConfigurationException::fileNotFound($baseConfigPath); - } - - try { - $config = require $baseConfigPath; - return $this->withPluginMigrationPaths(ConfigurationValidator::validate($config)); - } catch (\Throwable $e) { - throw ConfigurationException::loadFailed($baseConfigPath, $e); - } - } - } - - /** - * Append every plugins/{Name}/database/migrations directory to the config - * "paths" so plugin-owned migrations (e.g. Auth, Authorization) run - * alongside the project's own. Idempotent — duplicates are removed. - * - * @param array $config - * @return array - */ - private function withPluginMigrationPaths(array $config): array - { - $pluginPaths = glob(Paths::base('plugins/*/database/migrations'), GLOB_ONLYDIR) ?: []; - if ($pluginPaths === []) { - return $config; - } - - $existing = $config['paths'] ?? (isset($config['path']) ? [(string) $config['path']] : []); - $config['paths'] = array_values(array_unique([...$existing, ...$pluginPaths])); - unset($config['path']); // normalise to the plural form - - return $config; - } - - private function getMinimalConfig(): array - { - // Fallback in-memory SQLite config for development - return [ - 'connections' => [ - 'default' => [ - 'driver' => 'sqlite', - 'host' => 'localhost', - 'database' => ':memory:', - 'username' => '', - 'password' => '', - ], - ], - 'paths' => array_values(array_unique([ - Paths::project('database/migrations'), - ...(glob(Paths::base('plugins/*/database/migrations'), GLOB_ONLYDIR) ?: []), - ])), - 'tracking_table' => 'let_migrations', - 'pretend' => false, - 'transactional' => false, - ]; - } - - private function isProduction(): bool - { - $env = (string) (env('APP_ENV') ?: 'local'); - return $env === 'production'; - } -} diff --git a/plugins/Commands/Secrets/SecretsManager.php b/plugins/Commands/Secrets/SecretsManager.php deleted file mode 100644 index a6c0a6c..0000000 --- a/plugins/Commands/Secrets/SecretsManager.php +++ /dev/null @@ -1,159 +0,0 @@ -retrieve($key); - } catch (SecretNotFoundException) { - if ($default === null) { - throw new SecretNotFoundException("Secret not found: {$key}"); - } - return $default; - } - } - - public static function has(string $key): bool - { - self::$vault ??= self::createVault(); - - try { - self::$vault->retrieve($key); - return true; - } catch (SecretNotFoundException) { - return false; - } - } - - private static function createVault(): SecretsVault - { - $provider = (string) (env('SECRETS_PROVIDER') ?: 'env'); - - return match ($provider) { - 'aws' => new AwsSecretsManagerVault(), - 'vault' => new HashiCorpVaultAdapter(), - 'env' => new EnvironmentVariableVault(), - default => new EnvironmentVariableVault(), // Default to env vars - }; - } -} - -interface SecretsVault -{ - public function retrieve(string $key): string; -} - -final class EnvironmentVariableVault implements SecretsVault -{ - public function retrieve(string $key): string - { - $value = env($key); - - if ($value === false) { - throw new SecretNotFoundException("Environment variable not found: {$key}"); - } - - return (string) $value; - } -} - -final class AwsSecretsManagerVault implements SecretsVault -{ - private $client; - - public function __construct() - { - if (!class_exists('Aws\SecretsManager\SecretsManagerClient')) { - throw new SecretNotFoundException( - 'AWS SDK not installed. Install via: composer require aws/aws-sdk-php' - ); - } - - $this->client = new \Aws\SecretsManager\SecretsManagerClient([ - 'version' => 'latest', - 'region' => env('AWS_REGION') ?: 'us-east-1', - ]); - } - - public function retrieve(string $key): string - { - try { - $result = $this->client->getSecretValue(['SecretId' => $key]); - - if (isset($result['SecretString'])) { - return $result['SecretString']; - } - - if (isset($result['SecretBinary'])) { - return base64_decode($result['SecretBinary']); - } - - throw new SecretNotFoundException("Secret has no value: {$key}"); - } catch (\Exception $e) { - throw new SecretNotFoundException("Failed to retrieve secret: {$e->getMessage()}"); - } - } -} - -final class HashiCorpVaultAdapter implements SecretsVault -{ - private string $address; - private string $token; - - public function __construct() - { - $this->address = env('VAULT_ADDR') ?: 'http://127.0.0.1:8200'; - $this->token = env('VAULT_TOKEN') ?: ''; - - if (!$this->token) { - throw new SecretNotFoundException('VAULT_TOKEN environment variable not set'); - } - } - - public function retrieve(string $key): string - { - $url = $this->address . '/v1/secret/data/' . $key; - - $ch = curl_init($url); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'X-Vault-Token: ' . $this->token, - 'Accept: application/json', - ]); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 5); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($httpCode !== 200) { - throw new SecretNotFoundException("Vault returned {$httpCode} for key: {$key}"); - } - - $data = json_decode($response, true); - $value = $data['data']['data']['value'] ?? null; - - if ($value === null) { - throw new SecretNotFoundException("Secret not found in Vault: {$key}"); - } - - return $value; - } -} - -final class SecretNotFoundException extends \RuntimeException -{ - public function __construct(string $message) - { - parent::__construct($message); - } -} diff --git a/plugins/Commands/Validation/PreFlightValidator.php b/plugins/Commands/Validation/PreFlightValidator.php deleted file mode 100644 index 9a3bfe3..0000000 --- a/plugins/Commands/Validation/PreFlightValidator.php +++ /dev/null @@ -1,107 +0,0 @@ -errors = []; - $this->warnings = []; - - $this->validateDatabaseConnection($config); - $this->validateMigrationPaths($config); - $this->validateTrackingTable($config); - - return new PreFlightReport( - valid: empty($this->errors), - errors: $this->errors, - warnings: $this->warnings - ); - } - - private function validateDatabaseConnection(array $config): void - { - // Test the default connection - $conn = $config['connections']['default'] ?? null; - if (!$conn) { - $this->errors[] = 'No default database connection configured'; - return; - } - - // Check if database is accessible - if (!$this->infrastructure->isDatabaseAccessible($config)) { - $this->errors[] = 'Database connection test failed'; - } - } - - private function validateMigrationPaths(array $config): void - { - $paths = $config['paths'] ?? []; - - if (empty($paths)) { - $this->errors[] = 'No migration paths configured'; - return; - } - - foreach ($paths as $path) { - if (!is_dir($path)) { - $this->warnings[] = "Migration path does not exist: {$path}"; - } - - if (!is_readable($path)) { - $this->errors[] = "Migration path is not readable: {$path}"; - } - } - } - - private function validateTrackingTable(array $config): void - { - $table = $config['tracking_table'] ?? 'let_migrations'; - - // Check if tracking table exists - if (!$this->infrastructure->doesTrackingTableExist($config)) { - $this->warnings[] = "Migration tracking table '{$table}' does not exist (will be created)"; - } - } -} - -final class PreFlightReport -{ - public function __construct( - public readonly bool $valid, - public readonly array $errors = [], - public readonly array $warnings = [], - ) {} - - public function isValid(): bool - { - return $this->valid; - } - - public function hasIssues(): bool - { - return !empty($this->errors) || !empty($this->warnings); - } - - public function getErrorCount(): int - { - return count($this->errors); - } - - public function getWarningCount(): int - { - return count($this->warnings); - } -} diff --git a/plugins/Commands/module.json b/plugins/Commands/module.json deleted file mode 100644 index 78d7d27..0000000 --- a/plugins/Commands/module.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "commands", - "version": "1.0.0", - "solves": "system.commands", - "type": "module", - "requires": [ - "logging.application" - ], - "exposes": [], - "commands": [ - "module:add", - "module:remove", - "migrate:run", - "migrate:rollback", - "migrate:reset", - "migrate:refresh", - "migrate:fresh", - "migrate:status", - "migrate:pending", - "migrate:install", - "migrate:to", - "migrate:redo", - "migrate:generate", - "migrate:diff", - "migrate:check", - "migrate:lint", - "migrate:squash", - "migrate:breakpoint", - "make:migration", - "make:seeder", - "make:factory", - "seed:run", - "db:seed", - "tenant:migrate", - "tenant:refresh", - "tenant:reset", - "tenant:rollback", - "tenant:status" - ], - "config": [] -} diff --git a/plugins/Cookie/Infrastructure/CookieJar.php b/plugins/Cookie/Infrastructure/CookieJar.php deleted file mode 100644 index 5a7380f..0000000 --- a/plugins/Cookie/Infrastructure/CookieJar.php +++ /dev/null @@ -1,142 +0,0 @@ -queue('theme', 'dark', maxAge: 3600); - * $jar->forget('legacy'); - * - * When an EncryptionPort is available, queued cookie VALUES are encrypted on - * flush (authenticated, tamper-evident) unless the cookie name is exempt. - * Reading an encrypted incoming cookie is symmetric: decrypt($request->cookie(...)). - */ -final class CookieJar -{ - /** @var array */ - private array $queued = []; - - /** - * @param list $exempt cookie names whose values are NOT encrypted - * @param array{lifetime?: int, path?: string, domain?: ?string, secure?: bool, http_only?: bool, same_site?: string} $defaults env-driven attribute defaults (see config/cookie.php) - */ - public function __construct( - private readonly ?EncryptionPort $encrypter = null, - private readonly array $exempt = [], - private readonly array $defaults = [], - ) {} - - /** - * Queue a cookie. Any attribute left null falls back to the configured - * default (config/cookie.php), so callers usually pass only name + value. - * $maxAge is in SECONDS; the default lifetime in config is in MINUTES. - */ - public function queue( - string $name, - string $value, - ?int $maxAge = null, - ?string $path = null, - ?string $domain = null, - ?bool $secure = null, - ?bool $httpOnly = null, - ?string $sameSite = null, - bool $raw = false, - ): void { - $this->queued[$name] = [ - 'value' => $value, - 'maxAge' => $maxAge ?? (int) ($this->defaults['lifetime'] ?? 0) * 60, - 'path' => $path ?? (string) ($this->defaults['path'] ?? '/'), - 'domain' => $domain ?? ($this->defaults['domain'] ?? null), - 'secure' => $secure ?? (bool) ($this->defaults['secure'] ?? true), - 'httpOnly' => $httpOnly ?? (bool) ($this->defaults['http_only'] ?? true), - 'sameSite' => $sameSite ?? (string) ($this->defaults['same_site'] ?? 'Lax'), - 'raw' => $raw, - ]; - } - - /** Queue a cookie that expires in ~5 years. */ - public function forever(string $name, string $value, string $path = '/', ?string $domain = null): void - { - $this->queue($name, $value, maxAge: 60 * 60 * 24 * 365 * 5, path: $path, domain: $domain); - } - - public function forget(string $name, string $path = '/', ?string $domain = null): void - { - $this->queue($name, '', maxAge: -1, path: $path, domain: $domain, raw: true); - } - - public function hasQueued(string $name): bool - { - return isset($this->queued[$name]); - } - - /** - * Read an incoming cookie and transparently decrypt it (the symmetric - * counterpart to applyTo()'s encryption). Exempt cookies are returned raw, - * mirroring how they were written. Returns null when absent or tampered. - */ - public function read(Request $request, string $name): ?string - { - $raw = $request->cookie($name); - if ($raw === null) { - return null; - } - return $this->isExempt($name) ? $raw : $this->decrypt($raw); - } - - /** Decrypt an incoming cookie value; returns null on tampering/format error. */ - public function decrypt(?string $value): ?string - { - if ($value === null || $value === '' || $this->encrypter === null) { - return $value; - } - try { - return $this->encrypter->decryptString($value); - } catch (\Throwable) { - return null; // tampered or not encrypted with our key - } - } - - /** - * Apply every queued cookie to the response (encrypting non-exempt, non-cleared - * values when an EncryptionPort is configured) and return the new response. - */ - public function applyTo(Response $response): Response - { - foreach ($this->queued as $name => $c) { - $value = $c['value']; - $clearing = $c['maxAge'] < 0 || $value === ''; - - if (!$clearing && !$c['raw'] && $this->encrypter !== null && !$this->isExempt($name)) { - $value = $this->encrypter->encryptString($value); - } - - $response = $response->withCookie( - name: $name, - value: $value, - maxAge: $c['maxAge'], - path: $c['path'], - domain: $c['domain'], - secure: $c['secure'], - httpOnly: $c['httpOnly'], - sameSite: $c['sameSite'], - ); - } - return $response; - } - - private function isExempt(string $name): bool - { - return in_array($name, $this->exempt, true); - } -} diff --git a/plugins/Cookie/Infrastructure/Http/QueuedCookiesStage.php b/plugins/Cookie/Infrastructure/Http/QueuedCookiesStage.php deleted file mode 100644 index 6c53ec6..0000000 --- a/plugins/Cookie/Infrastructure/Http/QueuedCookiesStage.php +++ /dev/null @@ -1,34 +0,0 @@ -container(); - if ($container === null || !$container->has(CookieJar::class)) { - return $next($request); - } - - $response = $next($request); - - $jar = $container->make(CookieJar::class); - return $jar instanceof CookieJar ? $jar->applyTo($response) : $response; - } -} diff --git a/plugins/Cookie/Provider.php b/plugins/Cookie/Provider.php deleted file mode 100644 index a499952..0000000 --- a/plugins/Cookie/Provider.php +++ /dev/null @@ -1,67 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [CookieJar::class]; - } - - public function register(ModuleContainer $container): void - { - if ($container->has(CookieJar::class)) { - return; - } - - $container->singleton(CookieJar::class, static function (ModuleContainer $c): CookieJar { - $encrypter = $c->has(EncryptionPort::class) ? $c->make(EncryptionPort::class) : null; - $config = cookie_config(); - - return new CookieJar( - encrypter: $encrypter instanceof EncryptionPort ? $encrypter : null, - exempt: $config['encrypt_exempt'] ?? [], - defaults: $config, - ); - }); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Flush queued cookies just before security headers decorate the response. - $http->hook('after.load', QueuedCookiesStage::class, priority: 25); - } -} diff --git a/plugins/Cookie/Support/helpers.php b/plugins/Cookie/Support/helpers.php deleted file mode 100644 index 6364d6d..0000000 --- a/plugins/Cookie/Support/helpers.php +++ /dev/null @@ -1,78 +0,0 @@ -/config/cookie.php wins over - * the plugin default. - * - * cookie_config(); // full array - * cookie_config('same_site'); // 'Lax' - * cookie_config('lifetime', 60) // value, or 60 if the key is absent - * - * @return mixed the whole config array, or a single key's value - */ - function cookie_config(?string $key = null, mixed $default = null): mixed - { - /** @var array|null $config */ - static $config = null; - - if ($config === null) { - $projectFile = Paths::config('cookie.php'); - $pluginFile = __DIR__ . '/../config/cookie.php'; - - $file = is_file($projectFile) ? $projectFile : $pluginFile; - $loaded = require $file; - $config = is_array($loaded) ? $loaded : []; - } - - if ($key === null) { - return $config; - } - - return $config[$key] ?? $default; - } -} - -if (!function_exists('cookie')) { - /** - * Build a cookie attribute set from config defaults — a ready-to-spread - * array for CookieJar::queue() or Response::withCookie(). - * - * // Queue via the request-scoped jar (auto-encrypted + flushed): - * $jar->queue(...cookie('theme', 'dark', minutes: 60)); - * - * // Or apply straight to a response: - * return Response::json($data)->withCookie(...cookie('seen', '1')); - * - * Pass $minutes to override the configured lifetime (config is in minutes; - * the returned `maxAge` is in seconds, as both consumers expect). Any other - * attribute can be overridden through $overrides (path/domain/secure/...). - * - * @param array $overrides - * @return array - */ - function cookie( - string $name, - string $value = '', - ?int $minutes = null, - array $overrides = [], - ): array { - $minutes = $minutes ?? (int) cookie_config('lifetime', 0); - - return array_merge([ - 'name' => $name, - 'value' => $value, - 'maxAge' => $minutes * 60, - 'path' => (string) cookie_config('path', '/'), - 'domain' => cookie_config('domain', null), - 'secure' => (bool) cookie_config('secure', true), - 'httpOnly' => (bool) cookie_config('http_only', true), - 'sameSite' => (string) cookie_config('same_site', 'Lax'), - ], $overrides); - } -} diff --git a/plugins/Cookie/config/cookie.php b/plugins/Cookie/config/cookie.php deleted file mode 100644 index 02ecdc7..0000000 --- a/plugins/Cookie/config/cookie.php +++ /dev/null @@ -1,88 +0,0 @@ -/config/cookie.php (project override — copy this file there) - * 2. plugins/Cookie/config/cookie.php (this file — framework default) - * - * Read it anywhere with the cookie_config() helper: - * cookie_config('lifetime'); // 120 - * cookie_config('same_site'); // 'Lax' - */ -return [ - - /* - |-------------------------------------------------------------------------- - | Default Lifetime (minutes) - |-------------------------------------------------------------------------- - | How long a queued cookie lives when no explicit lifetime is given. - | 0 = session cookie (cleared when the browser closes). - */ - 'lifetime' => (int) env('COOKIE_LIFETIME', 120), - - /* - |-------------------------------------------------------------------------- - | Path & Domain - |-------------------------------------------------------------------------- - | The URL path the cookie is valid for, and the domain scope. A null - | domain binds the cookie to the exact host that issued it. - */ - 'path' => (string) env('COOKIE_PATH', '/'), - 'domain' => env('COOKIE_DOMAIN', null) ?: null, - - /* - |-------------------------------------------------------------------------- - | Secure (HTTPS only) - |-------------------------------------------------------------------------- - | When true the cookie is only sent over TLS. Keep ON in production; set - | COOKIE_SECURE=false for local plain-http development. - */ - 'secure' => filter_var(env('COOKIE_SECURE', true), FILTER_VALIDATE_BOOL), - - /* - |-------------------------------------------------------------------------- - | HttpOnly (no JavaScript access) - |-------------------------------------------------------------------------- - | Hides the cookie from document.cookie — strong XSS defence. Disable only - | for cookies a front-end script must read. - */ - 'http_only' => filter_var(env('COOKIE_HTTP_ONLY', true), FILTER_VALIDATE_BOOL), - - /* - |-------------------------------------------------------------------------- - | SameSite - |-------------------------------------------------------------------------- - | CSRF mitigation: 'Lax' | 'Strict' | 'None'. 'None' requires secure=true. - */ - 'same_site' => (string) env('COOKIE_SAME_SITE', 'Lax'), - - /* - |-------------------------------------------------------------------------- - | Encryption Exemptions - |-------------------------------------------------------------------------- - | Cookie NAMES whose values are stored as PLAINTEXT (never encrypted on - | write, never decrypted on read) even when an EncryptionPort is bound. - | - | Exempt a cookie when its raw value must stay stable and readable as-is: - | - a JS-readable flag (theme, locale) the front-end reads directly; - | - an opaque session/binding cookie that a pre-load security layer reads - | RAW (e.g. CsrfTokenLayer's bindCookie). Encryption rotates the - | ciphertext each response, which would break that binding — exempting - | it keeps the value byte-stable across requests. - | - | The final list is the base names below MERGED with the comma-separated - | COOKIE_ENCRYPT_EXEMPT env var (so deployments can add more without code). - */ - 'encrypt_exempt' => array_values(array_unique(array_filter(array_map('trim', array_merge( - [ - // 'hkm_session', // CSRF bindCookie — must stay raw/stable for the security stage - ], - explode(',', (string) (env('COOKIE_ENCRYPT_EXEMPT') ?: '')), - ))))), - -]; diff --git a/plugins/Cookie/module.json b/plugins/Cookie/module.json deleted file mode 100644 index 6f28464..0000000 --- a/plugins/Cookie/module.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "cookie", - "version": "1.0.0", - "solves": "http.cookies", - "type": "module", - - "requires": [], - "exposes": ["Plugins\\Cookie\\Infrastructure\\CookieJar"], - - "routes": [], - "emits": [], - "listens": [], - - "config": [ - { "key": "COOKIE_ENCRYPT_EXEMPT", "type": "string", "required": false } - ] -} diff --git a/plugins/Crypto/Infrastructure/AesEncrypter.php b/plugins/Crypto/Infrastructure/AesEncrypter.php deleted file mode 100644 index a9436f9..0000000 --- a/plugins/Crypto/Infrastructure/AesEncrypter.php +++ /dev/null @@ -1,131 +0,0 @@ - raw 32-byte keys; index 0 is current */ - private readonly array $keys; - - /** - * @param string|list $keys base64 (with optional "base64:" prefix) or raw 32-byte keys - */ - public function __construct(string|array $keys) - { - $normalized = array_map([self::class, 'normalizeKey'], (array) $keys); - $normalized = array_values(array_filter($normalized, static fn(string $k) => $k !== '')); - - if ($normalized === []) { - throw new KernelException('AesEncrypter requires at least one 32-byte key.', layer: 'crypto.encrypter'); - } - foreach ($normalized as $k) { - if (strlen($k) !== 32) { - throw new KernelException('AesEncrypter keys must be exactly 32 bytes (AES-256).', layer: 'crypto.encrypter'); - } - } - $this->keys = $normalized; - } - - public function encrypt(mixed $value, bool $serialize = true): string - { - $plaintext = $serialize ? serialize($value) : (string) $value; - $iv = random_bytes(12); // 96-bit nonce for GCM - $tag = ''; - - $cipher = openssl_encrypt($plaintext, self::CIPHER, $this->keys[0], OPENSSL_RAW_DATA, $iv, $tag); - if ($cipher === false) { - throw new KernelException('Encryption failed.', layer: 'crypto.encrypter'); - } - - $json = json_encode([ - 'iv' => base64_encode($iv), - 'value' => base64_encode($cipher), - 'tag' => base64_encode($tag), - 'kid' => 0, - ], JSON_THROW_ON_ERROR); - - return base64_encode($json); - } - - public function decrypt(string $payload, bool $unserialize = true): mixed - { - $decoded = base64_decode($payload, true); - if ($decoded === false) { - throw new KernelException('Malformed encryption payload.', layer: 'crypto.encrypter'); - } - - try { - $parts = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - throw new KernelException('Malformed encryption payload.', layer: 'crypto.encrypter', previous: $e); - } - if (!is_array($parts) || !isset($parts['iv'], $parts['value'], $parts['tag'])) { - throw new KernelException('Malformed encryption payload.', layer: 'crypto.encrypter'); - } - - $iv = base64_decode((string) $parts['iv'], true); - $cipher = base64_decode((string) $parts['value'], true); - $tag = base64_decode((string) $parts['tag'], true); - if ($iv === false || $cipher === false || $tag === false) { - throw new KernelException('Malformed encryption payload.', layer: 'crypto.encrypter'); - } - - // Try the recorded key first, then every other key (rotation support). - $order = $this->keyTryOrder(is_int($parts['kid'] ?? null) ? $parts['kid'] : 0); - foreach ($order as $kid) { - $plain = openssl_decrypt($cipher, self::CIPHER, $this->keys[$kid], OPENSSL_RAW_DATA, $iv, $tag); - if ($plain !== false) { - return $unserialize ? unserialize($plain) : $plain; - } - } - - throw new KernelException('Could not decrypt payload (invalid key or tampered data).', layer: 'crypto.encrypter'); - } - - public function encryptString(string $value): string - { - return $this->encrypt($value, false); - } - - public function decryptString(string $payload): string - { - return (string) $this->decrypt($payload, false); - } - - /** @return list */ - private function keyTryOrder(int $preferred): array - { - $indices = array_keys($this->keys); - if (!isset($this->keys[$preferred])) { - return $indices; - } - return [$preferred, ...array_values(array_filter($indices, static fn(int $i) => $i !== $preferred))]; - } - - private static function normalizeKey(string $key): string - { - if (str_starts_with($key, 'base64:')) { - $key = substr($key, 7); - } - $decoded = base64_decode($key, true); - // If it decodes cleanly to 32 bytes, treat as base64; else use raw. - return ($decoded !== false && strlen($decoded) === 32) ? $decoded : $key; - } -} diff --git a/plugins/Crypto/Infrastructure/PasswordHasher.php b/plugins/Crypto/Infrastructure/PasswordHasher.php deleted file mode 100644 index 85d10aa..0000000 --- a/plugins/Crypto/Infrastructure/PasswordHasher.php +++ /dev/null @@ -1,59 +0,0 @@ -algo, $this->options($options)); - if (!is_string($hash)) { - throw new KernelException('Password hashing failed.', layer: 'crypto.hasher'); - } - return $hash; - } - - public function check(string $value, string $hashedValue): bool - { - return $hashedValue !== '' && password_verify($value, $hashedValue); - } - - public function needsRehash(string $hashedValue, array $options = []): bool - { - return password_needs_rehash($hashedValue, $this->algo, $this->options($options)); - } - - /** - * @param array $options - * @return array - */ - private function options(array $options): array - { - if ($this->algo === PASSWORD_BCRYPT) { - return ['cost' => (int) ($options['cost'] ?? $this->cost)]; - } - // Argon2 parameters fall back to PHP defaults unless overridden. - return array_filter([ - 'memory_cost' => isset($options['memory_cost']) ? (int) $options['memory_cost'] : null, - 'time_cost' => isset($options['time_cost']) ? (int) $options['time_cost'] : null, - 'threads' => isset($options['threads']) ? (int) $options['threads'] : null, - ], static fn($v) => $v !== null); - } -} diff --git a/plugins/Crypto/Provider.php b/plugins/Crypto/Provider.php deleted file mode 100644 index 7aa11e1..0000000 --- a/plugins/Crypto/Provider.php +++ /dev/null @@ -1,68 +0,0 @@ -withPorts([...])). This Provider additionally rebinds them into the - * request-scoped container so module services can inject the ports directly. - */ -final class Provider implements ModuleContract -{ - public function solves(): string - { - return 'crypto.services'; - } - - /** @return list */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [EncryptionPort::class, HashingPort::class]; - } - - public function register(ModuleContainer $container): void - { - // Fallback bindings so the ports resolve even if a project forgot to - // wire them in withPorts(). Reads keys/cost from env. - if (!$container->has(HashingPort::class)) { - $container->bind(HashingPort::class, static fn() => new PasswordHasher( - cost: (int) (env('HASH_BCRYPT_COST') ?: 12), - )); - } - if (!$container->has(EncryptionPort::class)) { - $container->bind(EncryptionPort::class, static function () { - $keys = array_values(array_filter([ - env('APP_KEY') ?: '', - env('APP_KEY_PREVIOUS') ?: '', - ], static fn(string $k) => $k !== '')); - return new AesEncrypter($keys === [] ? str_repeat('0', 32) : $keys); - }); - } - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - } -} diff --git a/plugins/Crypto/module.json b/plugins/Crypto/module.json deleted file mode 100644 index 8564a92..0000000 --- a/plugins/Crypto/module.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "crypto", - "version": "1.0.0", - "solves": "crypto.services", - "type": "module", - - "requires": [], - "exposes": [ - "AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\EncryptionPort", - "AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\HashingPort" - ], - - "routes": [], - "emits": [], - "listens": [], - - "config": [ - { "key": "APP_KEY", "type": "string", "required": false }, - { "key": "APP_KEY_PREVIOUS", "type": "string", "required": false }, - { "key": "HASH_BCRYPT_COST", "type": "int", "required": false } - ] -} diff --git a/plugins/Database/API/Contracts/DatabaseConfigurationContract.php b/plugins/Database/API/Contracts/DatabaseConfigurationContract.php deleted file mode 100644 index 3350796..0000000 --- a/plugins/Database/API/Contracts/DatabaseConfigurationContract.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ - public function initStatements(): array; - - /** - * Get all configuration as array. MUST NOT expose the password. - * - * @return array - */ - public function toArray(): array; -} diff --git a/plugins/Database/API/Contracts/DatabaseConnectionManagerContract.php b/plugins/Database/API/Contracts/DatabaseConnectionManagerContract.php deleted file mode 100644 index 997b1b3..0000000 --- a/plugins/Database/API/Contracts/DatabaseConnectionManagerContract.php +++ /dev/null @@ -1,51 +0,0 @@ - 'mysql', - 'mariadb' => 'mysql', - 'pgsql' => 'pgsql', - 'postgres' => 'pgsql', - 'postgresql' => 'pgsql', - 'sqlite' => 'sqlite', - 'sqlite3' => 'sqlite', - 'sqlsrv' => 'sqlsrv', - 'mssql' => 'sqlsrv', - 'sqlserver' => 'sqlsrv', - 'sql-server' => 'sqlsrv', - ]; - - /** - * Build a configuration from a settings array. - * - * Recognised keys: driver, host, port, database, username, password, - * charset, ssl_mode, ssl_verify, ssl_ca, unix_socket, trust_server_certificate, - * encrypt. - * - * @param array $settings - * @throws ConnectionException when the driver is unknown - */ - public function make(array $settings): DatabaseConfigurationContract - { - $requested = strtolower((string) ($settings['driver'] ?? 'sqlite')); - $driver = self::ALIASES[$requested] ?? null; - - if ($driver === null) { - throw ConnectionException::unsupportedDriver($requested); - } - - return match ($driver) { - 'mysql' => $this->mysql($settings), - 'pgsql' => $this->postgres($settings), - 'sqlite' => $this->sqlite($settings), - 'sqlsrv' => $this->sqlServer($settings), - }; - } - - /** - * Build a configuration by reading DB_* environment variables. - */ - public function fromEnvironment(): DatabaseConfigurationContract - { - $env = static fn (string $key): ?string => ($v = env($key)) === false ? null : $v; - - return $this->make([ - 'driver' => $env('DB_DRIVER') ?? 'sqlite', - 'host' => $env('DB_HOST'), - 'port' => $env('DB_PORT'), - 'database' => $env('DB_DATABASE') ?? $env('DB_NAME'), - 'username' => $env('DB_USERNAME'), - 'password' => $env('DB_PASSWORD'), - 'charset' => $env('DB_CHARSET'), - 'ssl_mode' => $env('DB_SSL_MODE'), - 'ssl_verify' => $env('DB_SSL_VERIFY'), - 'ssl_ca' => $env('DB_SSL_CA'), - 'unix_socket' => $env('DB_UNIX_SOCKET'), - 'trust_server_certificate' => $env('DB_TRUST_SERVER_CERT'), - 'encrypt' => $env('DB_ENCRYPT'), - ]); - } - - private function mysql(array $s): MySQLConfiguration - { - return new MySQLConfiguration( - host: (string) ($s['host'] ?? 'localhost'), - port: (int) ($s['port'] ?? 3306), - database: (string) ($s['database'] ?? ''), - username: (string) ($s['username'] ?? 'root'), - password: (string) ($s['password'] ?? ''), - charset: (string) ($s['charset'] ?? 'utf8mb4'), - useSslVerify: $this->bool($s['ssl_verify'] ?? false), - sslCa: $this->nullableString($s['ssl_ca'] ?? null), - unixSocket: $this->nullableString($s['unix_socket'] ?? null), - ); - } - - private function postgres(array $s): PostgreSQLConfiguration - { - return new PostgreSQLConfiguration( - host: (string) ($s['host'] ?? 'localhost'), - port: (int) ($s['port'] ?? 5432), - database: (string) ($s['database'] ?? 'postgres'), - username: (string) ($s['username'] ?? 'postgres'), - password: (string) ($s['password'] ?? ''), - sslMode: (string) ($s['ssl_mode'] ?? 'prefer'), - unixSocket: $this->nullableString($s['unix_socket'] ?? null), - ); - } - - private function sqlite(array $s): SQLiteConfiguration - { - $path = $this->nullableString($s['database'] ?? null) ?? ':memory:'; - - return new SQLiteConfiguration(path: $path); - } - - private function sqlServer(array $s): SqlServerConfiguration - { - return new SqlServerConfiguration( - server: (string) ($s['host'] ?? 'localhost'), - port: (int) ($s['port'] ?? 1433), - database: (string) ($s['database'] ?? ''), - username: (string) ($s['username'] ?? 'sa'), - password: (string) ($s['password'] ?? ''), - trustServerCertificate: $this->bool($s['trust_server_certificate'] ?? false), - encrypt: $this->bool($s['encrypt'] ?? false), - ); - } - - private function bool(mixed $value): bool - { - if (is_bool($value)) { - return $value; - } - - return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true); - } - - private function nullableString(mixed $value): ?string - { - if ($value === null || $value === '') { - return null; - } - - return (string) $value; - } -} diff --git a/plugins/Database/Infrastructure/Drivers/MySQLConfiguration.php b/plugins/Database/Infrastructure/Drivers/MySQLConfiguration.php deleted file mode 100644 index 01768c9..0000000 --- a/plugins/Database/Infrastructure/Drivers/MySQLConfiguration.php +++ /dev/null @@ -1,98 +0,0 @@ -unixSocket) { - return "mysql:unix_socket={$this->unixSocket};dbname={$this->database};charset={$this->charset}"; - } - - return "mysql:host={$this->host};port={$this->port};dbname={$this->database};charset={$this->charset}"; - } - - public function username(): ?string - { - return $this->username ?: null; - } - - public function password(): ?string - { - return $this->password ?: null; - } - - public function pdoOptions(): array - { - $options = [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, - // Pin the session to UTC here (not only in initStatements): PDO - // re-runs INIT_COMMAND on every connect AND auto-reconnect, so - // CURRENT_TIMESTAMP/NOW() and TIMESTAMP read-back stay UTC even after - // a dropped connection. '+00:00' is a numeric offset (no tz tables). - PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES {$this->charset}, time_zone = '+00:00'", - ]; - - if ($this->sslCa && $this->useSslVerify) { - $options[PDO::MYSQL_ATTR_SSL_CA] = $this->sslCa; - $options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = true; - } - - return $options; - } - - public function initStatements(): array - { - // Charset is already applied via MYSQL_ATTR_INIT_COMMAND; strict mode is - // enforced here so silent truncation/coercion never reaches production. - // The session timezone is pinned to UTC ('+00:00' is a numeric offset, so - // it needs no timezone tables): CURRENT_TIMESTAMP / NOW() and TIMESTAMP - // read-back are then unambiguously UTC, matching the PHP-side UTC clock. - return [ - "SET SESSION sql_mode = 'STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION'", - "SET time_zone = '+00:00'", - ]; - } - - public function toArray(): array - { - return [ - 'driver' => $this->driver(), - 'host' => $this->host, - 'port' => $this->port, - 'database' => $this->database, - 'username' => $this->username, - 'charset' => $this->charset, - 'unix_socket' => $this->unixSocket, - ]; - } -} diff --git a/plugins/Database/Infrastructure/Drivers/PostgreSQLConfiguration.php b/plugins/Database/Infrastructure/Drivers/PostgreSQLConfiguration.php deleted file mode 100644 index 8998462..0000000 --- a/plugins/Database/Infrastructure/Drivers/PostgreSQLConfiguration.php +++ /dev/null @@ -1,76 +0,0 @@ -unixSocket) { - return "pgsql:host={$this->unixSocket};dbname={$this->database};sslmode={$this->sslMode}"; - } - - return "pgsql:host={$this->host};port={$this->port};dbname={$this->database};sslmode={$this->sslMode}"; - } - - public function username(): ?string - { - return $this->username ?: null; - } - - public function password(): ?string - { - return $this->password ?: null; - } - - public function pdoOptions(): array - { - return [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, - ]; - } - - public function initStatements(): array - { - return []; - } - - public function toArray(): array - { - return [ - 'driver' => $this->driver(), - 'host' => $this->host, - 'port' => $this->port, - 'database' => $this->database, - 'username' => $this->username, - 'ssl_mode' => $this->sslMode, - 'unix_socket' => $this->unixSocket, - ]; - } -} diff --git a/plugins/Database/Infrastructure/Drivers/SQLiteConfiguration.php b/plugins/Database/Infrastructure/Drivers/SQLiteConfiguration.php deleted file mode 100644 index b20d6ee..0000000 --- a/plugins/Database/Infrastructure/Drivers/SQLiteConfiguration.php +++ /dev/null @@ -1,76 +0,0 @@ -path}"; - } - - public function username(): ?string - { - return null; - } - - public function password(): ?string - { - return null; - } - - public function pdoOptions(): array - { - return [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, - PDO::SQLITE_ATTR_OPEN_FLAGS => $this->flags, - ]; - } - - public function initStatements(): array - { - // SQLite disables foreign-key enforcement by default — enforce it, and - // set a busy timeout so concurrent writers wait rather than fail instantly. - $statements = [ - 'PRAGMA foreign_keys = ON', - 'PRAGMA busy_timeout = 5000', - ]; - - // WAL improves read/write concurrency but is meaningless for :memory:. - if ($this->path !== ':memory:') { - $statements[] = 'PRAGMA journal_mode = WAL'; - } - - return $statements; - } - - public function toArray(): array - { - return [ - 'driver' => $this->driver(), - 'path' => $this->path, - 'in_memory' => $this->path === ':memory:', - ]; - } -} diff --git a/plugins/Database/Infrastructure/Drivers/SqlServerConfiguration.php b/plugins/Database/Infrastructure/Drivers/SqlServerConfiguration.php deleted file mode 100644 index b0d14b9..0000000 --- a/plugins/Database/Infrastructure/Drivers/SqlServerConfiguration.php +++ /dev/null @@ -1,86 +0,0 @@ -server},{$this->port};Database={$this->database}"; - } - - public function username(): ?string - { - return $this->username ?: null; - } - - public function password(): ?string - { - return $this->password ?: null; - } - - public function pdoOptions(): array - { - $options = [ - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - PDO::ATTR_EMULATE_PREPARES => false, - ]; - - if ($this->trustServerCertificate) { - $options['TrustServerCertificate'] = true; - } - - if ($this->encrypt) { - $options['Encrypt'] = true; - } - - return $options; - } - - public function initStatements(): array - { - // XACT_ABORT guarantees the whole transaction is rolled back on any - // run-time error — matching the fail-fast semantics of the other drivers. - return [ - 'SET XACT_ABORT ON', - ]; - } - - public function toArray(): array - { - return [ - 'driver' => $this->driver(), - 'server' => $this->server, - 'port' => $this->port, - 'database' => $this->database, - 'username' => $this->username, - 'trust_server_certificate' => $this->trustServerCertificate, - 'encrypt' => $this->encrypt, - ]; - } -} diff --git a/plugins/Database/Infrastructure/Persistence/ConnectionManager.php b/plugins/Database/Infrastructure/Persistence/ConnectionManager.php deleted file mode 100644 index 84a948f..0000000 --- a/plugins/Database/Infrastructure/Persistence/ConnectionManager.php +++ /dev/null @@ -1,88 +0,0 @@ - */ - private array $configs = []; - - /** @var array */ - private array $resolved = []; - - public function __construct( - private readonly string $defaultName = 'default', - private readonly ?LoggerPort $logger = null, - private readonly bool $logQueries = false, - ) {} - - public function connection(string $name = 'default'): DatabasePort - { - if (isset($this->resolved[$name])) { - return $this->resolved[$name]; - } - - if (!isset($this->configs[$name])) { - throw ConnectionException::unknownConnection($name); - } - - return $this->resolved[$name] = new MultiDriverDatabaseAdapter( - config: $this->configs[$name], - logger: $this->logger, - logQueries: $this->logQueries, - ); - } - - public function default(): DatabasePort - { - return $this->connection($this->defaultName); - } - - public function register(string $name, DatabaseConfigurationContract $config): void - { - $this->configs[$name] = $config; - // Drop any previously resolved adapter so the new config takes effect. - unset($this->resolved[$name]); - } - - public function has(string $name): bool - { - return isset($this->configs[$name]); - } - - /** - * @return list - */ - public function connections(): array - { - return array_keys($this->configs); - } - - public function close(string $name = 'default'): void - { - unset($this->resolved[$name]); - } - - public function closeAll(): void - { - $this->resolved = []; - } -} diff --git a/plugins/Database/Infrastructure/Persistence/MultiDriverDatabaseAdapter.php b/plugins/Database/Infrastructure/Persistence/MultiDriverDatabaseAdapter.php deleted file mode 100644 index f865b51..0000000 --- a/plugins/Database/Infrastructure/Persistence/MultiDriverDatabaseAdapter.php +++ /dev/null @@ -1,479 +0,0 @@ -savepoints = new SavepointGrammar($config->driver()); - } - - // ───────────────────────────────────────── connection lifecycle ───────── - - /** - * Return the live PDO handle, connecting lazily on first access. - * - * @throws ConnectionException - */ - public function pdo(): PDO - { - if ($this->pdo === null) { - $this->connect(); - } - - return $this->pdo; - } - - /** - * @throws ConnectionException - */ - private function connect(): void - { - try { - $this->pdo = new PDO( - $this->config->dsn(), - $this->config->username(), - $this->config->password(), - $this->config->pdoOptions(), - ); - - foreach ($this->config->initStatements() as $statement) { - $this->pdo->exec($statement); - } - } catch (PDOException $e) { - $this->pdo = null; - throw ConnectionException::connectionFailed( - $this->config->driver(), - $e->getMessage(), - $e, - ); - } - } - - /** - * Drop and rebuild the underlying connection. Resets transaction state. - * - * @throws ConnectionException - */ - public function reconnect(): void - { - $this->pdo = null; - $this->transactionLevel = 0; - $this->connect(); - } - - /** - * Lightweight health check — issues a trivial round-trip to the server. - */ - public function ping(): bool - { - try { - $this->pdo()->query('SELECT 1'); - return true; - } catch (\Throwable) { - return false; - } - } - - public function isConnected(): bool - { - return $this->pdo !== null; - } - - /** - * Deterministically close the connection, dropping the PDO handle and any - * in-flight transaction state. The next operation reconnects lazily. Used by - * the connection pool to release sockets without waiting for GC. - */ - public function close(): void - { - $this->pdo = null; - $this->transactionLevel = 0; - } - - // ───────────────────────────────────────────────────── reads/writes ───── - - public function query(string $sql, array $params = []): array - { - return $this->run('query', $sql, $params, static fn (PDOStatement $s): array => $s->fetchAll()); - } - - public function queryOne(string $sql, array $params = []): ?array - { - return $this->run('query', $sql, $params, static function (PDOStatement $s): ?array { - $row = $s->fetch(); - return $row === false ? null : $row; - }); - } - - public function execute(string $sql, array $params = []): int - { - return $this->run('execute', $sql, $params, static fn (PDOStatement $s): int => $s->rowCount()); - } - - public function upsert(string $table, array $values, array $conflictColumns, ?array $updateColumns = null): int - { - if ($values === []) { - return 0; - } - - return $this->execute( - $this->compileUpsert($table, array_keys($values), $conflictColumns, $updateColumns), - $values, - ); - } - - /** - * Compile a driver-correct upsert. MySQL uses `ON DUPLICATE KEY UPDATE` with - * `VALUES(col)`; PostgreSQL and SQLite use `ON CONFLICT (cols) DO UPDATE SET - * col = EXCLUDED.col`. Identifiers are quoted per driver. Values bind by - * column name (`:col`), reusing the same bindings for both INSERT and UPDATE. - * - * @param string[] $columns - * @param string[] $conflictColumns - * @param string[]|null $updateColumns - */ - private function compileUpsert(string $table, array $columns, array $conflictColumns, ?array $updateColumns): string - { - // Default: overwrite every column that is not part of the conflict key. - $updateColumns ??= array_values(array_diff($columns, $conflictColumns)); - - $cols = implode(', ', array_map($this->quoteId(...), $columns)); - $placeholders = implode(', ', array_map(static fn (string $c): string => ':' . $c, $columns)); - $insert = "INSERT INTO {$this->quoteId($table)} ({$cols}) VALUES ({$placeholders})"; - - $driver = $this->driver(); - - if ($driver === 'mysql') { - if ($updateColumns === []) { - // No-op assignment keeps the row untouched on conflict (insert-if-absent). - $keep = $this->quoteId($conflictColumns[0] ?? $columns[0]); - return "{$insert} ON DUPLICATE KEY UPDATE {$keep} = {$keep}"; - } - $set = implode(', ', array_map( - fn (string $c): string => "{$this->quoteId($c)} = VALUES({$this->quoteId($c)})", - $updateColumns, - )); - return "{$insert} ON DUPLICATE KEY UPDATE {$set}"; - } - - // PostgreSQL & SQLite — standard ON CONFLICT. - $target = implode(', ', array_map($this->quoteId(...), $conflictColumns)); - - if ($updateColumns === []) { - return "{$insert} ON CONFLICT ({$target}) DO NOTHING"; - } - - $set = implode(', ', array_map( - fn (string $c): string => "{$this->quoteId($c)} = EXCLUDED.{$this->quoteId($c)}", - $updateColumns, - )); - return "{$insert} ON CONFLICT ({$target}) DO UPDATE SET {$set}"; - } - - /** Quote a table/column identifier for the active driver. */ - private function quoteId(string $identifier): string - { - // Defensive: identifiers are first-party constants, never user input, but - // strip the quote chars so a stray one cannot break out of the quoting. - return $this->driver() === 'mysql' - ? '`' . str_replace('`', '', $identifier) . '`' - : '"' . str_replace('"', '', $identifier) . '"'; - } - - /** - * Execute a prepared statement and project the result via $reader. - * - * Centralises preparation, parameter binding, timing, logging, reconnect - * detection and error translation for every query path. - * - * @template T - * @param callable(PDOStatement): T $reader - * @return T - * @throws ConnectionException - */ - private function run(string $operation, string $sql, array $params, callable $reader): mixed - { - $startedAt = microtime(true); - - try { - $stmt = $this->pdo()->prepare($sql); - $stmt->execute($params); - $result = $reader($stmt); - $this->logQuery($sql, $params, $startedAt); - - return $result; - } catch (PDOException $e) { - // Outside a transaction a lost connection is safe to retry once. - if ($this->transactionLevel === 0 && $this->isConnectionLost($e)) { - $this->reconnect(); - - try { - $stmt = $this->pdo()->prepare($sql); - $stmt->execute($params); - $result = $reader($stmt); - $this->logQuery($sql, $params, $startedAt); - - return $result; - } catch (PDOException $retry) { - $e = $retry; - } - } - - throw $operation === 'execute' - ? ConnectionException::executionFailed($this->config->driver(), $sql, $e->getMessage(), $e) - : ConnectionException::queryFailed($this->config->driver(), $sql, $e->getMessage(), $e); - } - } - - /** - * Last auto-increment id. PostgreSQL requires the owning sequence name - * (e.g. "users_id_seq") to resolve the value for a specific table. - */ - public function lastInsertId(?string $sequence = null): string - { - try { - return $sequence !== null - ? (string) $this->pdo()->lastInsertId($sequence) - : (string) $this->pdo()->lastInsertId(); - } catch (PDOException $e) { - throw ConnectionException::queryFailed( - $this->config->driver(), - 'lastInsertId', - $e->getMessage(), - $e, - ); - } - } - - // ──────────────────────────────────────────────── transactions ────────── - - public function beginTransaction(): void - { - try { - if ($this->transactionLevel === 0) { - $this->pdo()->beginTransaction(); - } elseif ($this->savepoints->supportsSavepoints()) { - $this->pdo()->exec($this->savepoints->compileSavepoint( - $this->savepoints->name($this->transactionLevel), - )); - } - - $this->transactionLevel++; - } catch (PDOException $e) { - throw ConnectionException::transactionFailed( - $this->config->driver(), - 'begin', - $e->getMessage(), - $e, - ); - } - } - - public function commit(): void - { - if ($this->transactionLevel === 0) { - return; - } - - try { - if ($this->transactionLevel === 1) { - $this->pdo()->commit(); - } elseif ($this->savepoints->supportsSavepoints()) { - $release = $this->savepoints->compileRelease( - $this->savepoints->name($this->transactionLevel - 1), - ); - if ($release !== null) { - $this->pdo()->exec($release); - } - } - - $this->transactionLevel--; - } catch (PDOException $e) { - throw ConnectionException::transactionFailed( - $this->config->driver(), - 'commit', - $e->getMessage(), - $e, - ); - } - } - - public function rollback(): void - { - if ($this->transactionLevel === 0) { - return; - } - - try { - if ($this->transactionLevel === 1) { - $this->pdo()->rollBack(); - } elseif ($this->savepoints->supportsSavepoints()) { - $this->pdo()->exec($this->savepoints->compileRollbackTo( - $this->savepoints->name($this->transactionLevel - 1), - )); - } - - $this->transactionLevel--; - } catch (PDOException $e) { - throw ConnectionException::transactionFailed( - $this->config->driver(), - 'rollback', - $e->getMessage(), - $e, - ); - } - } - - public function inTransaction(): bool - { - return $this->transactionLevel > 0; - } - - /** - * Current nesting depth — exposed for diagnostics and tests. - */ - public function transactionLevel(): int - { - return $this->transactionLevel; - } - - /** - * Run $work inside a transaction, committing on success and rolling back on - * any throwable. Nests safely thanks to savepoints. Returns $work's value. - * - * @template T - * @param callable(self): T $work - * @return T - */ - public function transaction(callable $work): mixed - { - $this->beginTransaction(); - - try { - $result = $work($this); - $this->commit(); - - return $result; - } catch (\Throwable $e) { - $this->rollback(); - throw $e; - } - } - - // ──────────────────────────────────────────────────── introspection ───── - - public function driver(): string - { - return $this->config->driver(); - } - - public function configuration(): DatabaseConfigurationContract - { - return $this->config; - } - - // ───────────────────────────────────────────────────────── internals ──── - - /** - * Detect "connection gone away" style errors that are safe to retry. - */ - private function isConnectionLost(PDOException $e): bool - { - $sqlState = $e->getCode(); - - // 08S01 / 08003 / 08006 are SQLSTATE connection-exception classes. - if (\in_array((string) $sqlState, ['08S01', '08003', '08006', 'HY000'], true)) { - $needles = [ - 'server has gone away', - 'lost connection', - 'gone away', - 'broken pipe', - 'no connection to the server', - 'connection was killed', - 'ssl connection has been closed', - ]; - $message = strtolower($e->getMessage()); - - foreach ($needles as $needle) { - if (str_contains($message, $needle)) { - return true; - } - } - } - - return false; - } - - private function logQuery(string $sql, array $params, float $startedAt): void - { - if ($this->logger === null) { - return; - } - - $elapsedMs = (microtime(true) - $startedAt) * 1000.0; - - if ($elapsedMs >= $this->slowQueryThresholdMs) { - $this->logger->warning('Slow database query', [ - 'driver' => $this->config->driver(), - 'sql' => $sql, - 'bindings' => $params, - 'elapsed_ms' => round($elapsedMs, 3), - ]); - } elseif ($this->logQueries) { - $this->logger->debug('Database query', [ - 'driver' => $this->config->driver(), - 'sql' => $sql, - 'bindings' => $params, - 'elapsed_ms' => round($elapsedMs, 3), - ]); - } - } - - /** - * Releasing the adapter closes the PDO handle (PDO closes on last reference). - */ - public function __destruct() - { - $this->close(); - } -} diff --git a/plugins/Database/Infrastructure/Persistence/PooledDatabaseAdapter.php b/plugins/Database/Infrastructure/Persistence/PooledDatabaseAdapter.php deleted file mode 100644 index 552d0a6..0000000 --- a/plugins/Database/Infrastructure/Persistence/PooledDatabaseAdapter.php +++ /dev/null @@ -1,116 +0,0 @@ -pinned ??= $this->pool->acquire(); - } - - public function query(string $sql, array $params = []): array - { - return $this->connection()->query($sql, $params); - } - - public function queryOne(string $sql, array $params = []): ?array - { - return $this->connection()->queryOne($sql, $params); - } - - public function execute(string $sql, array $params = []): int - { - return $this->connection()->execute($sql, $params); - } - - public function upsert(string $table, array $values, array $conflictColumns, ?array $updateColumns = null): int - { - return $this->connection()->upsert($table, $values, $conflictColumns, $updateColumns); - } - - public function lastInsertId(?string $sequence = null): string - { - return $this->connection()->lastInsertId($sequence); - } - - public function beginTransaction(): void - { - $this->connection()->beginTransaction(); - } - - public function commit(): void - { - $this->connection()->commit(); - } - - public function rollback(): void - { - $this->connection()->rollback(); - } - - public function inTransaction(): bool - { - return $this->pinned !== null && $this->pinned->inTransaction(); - } - - /** - * Run $work inside a transaction on the pinned connection. - * - * @template T - * @param callable(MultiDriverDatabaseAdapter): T $work - * @return T - */ - public function transaction(callable $work): mixed - { - return $this->connection()->transaction($work); - } - - /** - * Return the borrowed connection to the pool. Safe to call repeatedly. - * Invoked at end-of-request; idempotent. - */ - public function release(): void - { - if ($this->pinned !== null) { - $this->pool->release($this->pinned); - $this->pinned = null; - } - } - - public function __destruct() - { - $this->release(); - } -} diff --git a/plugins/Database/Infrastructure/Persistence/SavepointGrammar.php b/plugins/Database/Infrastructure/Persistence/SavepointGrammar.php deleted file mode 100644 index 247fc2d..0000000 --- a/plugins/Database/Infrastructure/Persistence/SavepointGrammar.php +++ /dev/null @@ -1,65 +0,0 @@ -driver, ['mysql', 'pgsql', 'sqlite', 'sqlsrv'], true); - } - - public function compileSavepoint(string $name): string - { - return $this->driver === 'sqlsrv' - ? 'SAVE TRANSACTION ' . $this->escape($name) - : 'SAVEPOINT ' . $this->escape($name); - } - - /** - * SQL Server has no RELEASE SAVEPOINT — releasing is a no-op there. - */ - public function compileRelease(string $name): ?string - { - return $this->driver === 'sqlsrv' - ? null - : 'RELEASE SAVEPOINT ' . $this->escape($name); - } - - public function compileRollbackTo(string $name): string - { - return $this->driver === 'sqlsrv' - ? 'ROLLBACK TRANSACTION ' . $this->escape($name) - : 'ROLLBACK TO SAVEPOINT ' . $this->escape($name); - } - - /** - * Generate the canonical savepoint identifier for a given transaction depth. - */ - public function name(int $level): string - { - return 'gda_sp_' . $level; - } - - private function escape(string $name): string - { - // Savepoint names are framework-generated (gda_sp_N); still guard the identifier. - return preg_replace('/[^a-zA-Z0-9_]/', '', $name) ?? $name; - } -} diff --git a/plugins/Database/Infrastructure/Pool/ConnectionPool.php b/plugins/Database/Infrastructure/Pool/ConnectionPool.php deleted file mode 100644 index ddd0841..0000000 --- a/plugins/Database/Infrastructure/Pool/ConnectionPool.php +++ /dev/null @@ -1,244 +0,0 @@ - idle, ready-to-lend connections (LIFO). */ - private array $idle = []; - - /** @var array borrowed slots keyed by adapter object id. */ - private array $borrowed = []; - - private int $waiters = 0; - - private bool $closed = false; - - private bool $warmed = false; - - /** - * @param Closure(): MultiDriverDatabaseAdapter $factory creates a fresh adapter - */ - public function __construct( - private readonly Closure $factory, - private readonly PoolConfiguration $config, - private readonly string $driver = 'unknown', - ) {} - - /** - * Open the minimum number of connections up front. Idempotent. - */ - public function warmup(): void - { - if ($this->warmed) { - return; - } - $this->warmed = true; - - for ($i = 0; $i < $this->config->minConnections; $i++) { - $pc = $this->create(); - // Force a real connection so the first request does not pay for it. - $pc->adapter->ping(); - $this->idle[] = $pc; - } - } - - /** - * Borrow a connection, opening or waiting as needed. - * - * @throws ConnectionException when the pool is closed or stays exhausted - * past the acquire timeout - */ - public function acquire(): MultiDriverDatabaseAdapter - { - if ($this->closed) { - throw ConnectionException::poolClosed($this->driver); - } - - $deadline = microtime(true) + ($this->config->acquireTimeoutMs / 1000.0); - - while (true) { - // 1. Reuse an idle connection, discarding any that are stale/dead. - while (($pc = array_pop($this->idle)) !== null) { - if ($this->isExpired($pc) || !$this->isValid($pc)) { - $this->discard($pc); - continue; - } - - return $this->lend($pc); - } - - // 2. Grow the pool while under the ceiling. - if ($this->total() < $this->config->maxConnections) { - return $this->lend($this->create()); - } - - // 3. Saturated — wait for a release or give up at the deadline. - if (microtime(true) >= $deadline) { - throw ConnectionException::poolExhausted( - $this->driver, - $this->config->maxConnections, - $this->config->acquireTimeoutMs, - ); - } - - $this->waiters++; - $this->sleepBriefly(); - $this->waiters--; - } - } - - /** - * Return a previously-acquired connection to the pool. - * - * Foreign or double releases are ignored. A connection still inside a - * transaction is rolled back before re-entering the pool so the next - * borrower receives a clean session. - */ - public function release(MultiDriverDatabaseAdapter $adapter): void - { - $id = spl_object_id($adapter); - $pc = $this->borrowed[$id] ?? null; - - if ($pc === null) { - return; - } - - unset($this->borrowed[$id]); - - if ($this->closed || $this->isExpired($pc)) { - $this->discard($pc); - return; - } - - try { - while ($adapter->inTransaction()) { - $adapter->rollback(); - } - } catch (\Throwable) { - // A connection we cannot clean up is not safe to reuse. - $this->discard($pc); - return; - } - - $pc->touch(); - $this->idle[] = $pc; - } - - /** - * Permanently close the pool and drop every connection. Borrowed - * connections are dropped as they return. - */ - public function close(): void - { - $this->closed = true; - $this->idle = []; - $this->borrowed = []; - } - - public function isClosed(): bool - { - return $this->closed; - } - - /** - * Live counters for health endpoints and tests. - * - * @return array{idle:int, active:int, total:int, max:int, min:int, waiters:int, closed:bool} - */ - public function stats(): array - { - return [ - 'idle' => count($this->idle), - 'active' => count($this->borrowed), - 'total' => $this->total(), - 'max' => $this->config->maxConnections, - 'min' => $this->config->minConnections, - 'waiters' => $this->waiters, - 'closed' => $this->closed, - ]; - } - - // ─────────────────────────────────────────────────────────── internals ── - - private function total(): int - { - return count($this->idle) + count($this->borrowed); - } - - private function create(): PooledConnection - { - return new PooledConnection(($this->factory)(), microtime(true)); - } - - private function lend(PooledConnection $pc): MultiDriverDatabaseAdapter - { - $this->borrowed[spl_object_id($pc->adapter)] = $pc; - - return $pc->adapter; - } - - private function discard(PooledConnection $pc): void - { - // Close deterministically rather than waiting for the GC to drop the - // last reference — pooled sockets should be freed promptly. - $pc->adapter->close(); - } - - private function isExpired(PooledConnection $pc): bool - { - if ($this->config->maxLifetimeSec > 0 && $pc->ageSeconds() > $this->config->maxLifetimeSec) { - return true; - } - - return $this->config->idleTimeoutSec > 0 && $pc->idleSeconds() > $this->config->idleTimeoutSec; - } - - private function isValid(PooledConnection $pc): bool - { - return !$this->config->validateOnAcquire || $pc->adapter->ping(); - } - - /** - * Yield to the OpenSwoole scheduler when in a coroutine; otherwise spin-wait. - */ - private function sleepBriefly(): void - { - if (\class_exists('\OpenSwoole\Coroutine') && \OpenSwoole\Coroutine::getCid() > 0) { - \OpenSwoole\Coroutine::usleep(1000); - return; - } - - if (\class_exists('\Swoole\Coroutine') && \Swoole\Coroutine::getCid() > 0) { - \Swoole\Coroutine::usleep(1000); - return; - } - - usleep(1000); - } -} diff --git a/plugins/Database/Infrastructure/Pool/PoolConfiguration.php b/plugins/Database/Infrastructure/Pool/PoolConfiguration.php deleted file mode 100644 index bcbb099..0000000 --- a/plugins/Database/Infrastructure/Pool/PoolConfiguration.php +++ /dev/null @@ -1,74 +0,0 @@ -maxConnections < 1) { - throw new InvalidArgumentException('maxConnections must be >= 1.'); - } - if ($this->minConnections < 0 || $this->minConnections > $this->maxConnections) { - throw new InvalidArgumentException('minConnections must be between 0 and maxConnections.'); - } - if ($this->acquireTimeoutMs < 0) { - throw new InvalidArgumentException('acquireTimeoutMs must be >= 0.'); - } - if ($this->idleTimeoutSec < 0 || $this->maxLifetimeSec < 0) { - throw new InvalidArgumentException('Timeouts must be >= 0.'); - } - } - - /** - * Build pool settings from DB_POOL_* environment variables. - */ - public static function fromEnvironment(): self - { - $int = static function (string $key, int $default): int { - $value = env($key); - - return $value === null || $value === false || $value === '' ? $default : (int) $value; - }; - $bool = static function (string $key, bool $default): bool { - $value = env($key); - if ($value === null || $value === false || $value === '') { - return $default; - } - - return \in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true); - }; - - // DB_POOL_SIZE is accepted as an alias for the maximum size. - $max = $int('DB_POOL_MAX', $int('DB_POOL_SIZE', 10)); - - return new self( - minConnections: $int('DB_POOL_MIN', 0), - maxConnections: $max, - acquireTimeoutMs: $int('DB_POOL_ACQUIRE_TIMEOUT_MS', 3000), - idleTimeoutSec: $int('DB_POOL_IDLE_TIMEOUT', 60), - maxLifetimeSec: $int('DB_POOL_MAX_LIFETIME', 3600), - validateOnAcquire: $bool('DB_POOL_VALIDATE', true), - ); - } -} diff --git a/plugins/Database/Infrastructure/Pool/PooledConnection.php b/plugins/Database/Infrastructure/Pool/PooledConnection.php deleted file mode 100644 index 7c29f0d..0000000 --- a/plugins/Database/Infrastructure/Pool/PooledConnection.php +++ /dev/null @@ -1,40 +0,0 @@ -lastUsedAt = $createdAt; - } - - public function touch(): void - { - $this->lastUsedAt = microtime(true); - } - - /** Seconds since this connection was created. */ - public function ageSeconds(): float - { - return microtime(true) - $this->createdAt; - } - - /** Seconds since this connection was last used. */ - public function idleSeconds(): float - { - return microtime(true) - $this->lastUsedAt; - } -} diff --git a/plugins/Database/Provider.php b/plugins/Database/Provider.php deleted file mode 100644 index ebb50c5..0000000 --- a/plugins/Database/Provider.php +++ /dev/null @@ -1,157 +0,0 @@ -boolEnv('DB_ENABLE_QUERY_LOG'); - - // Active connection configuration, resolved once from the environment. - $container->singleton(DatabaseConfigurationContract::class, static fn (): DatabaseConfigurationContract => - (new DatabaseConfigurationFactory())->fromEnvironment() - ); - - if ($this->boolEnv('DB_POOL_ENABLED')) { - $this->registerPooledPort($container, $logQueries); - } else { - // Kernel port — repositories depend on this interface only. - $container->bind(DatabasePort::class, static fn ($c): DatabasePort => - new MultiDriverDatabaseAdapter( - config: $c->make(DatabaseConfigurationContract::class), - logger: self::optionalLogger($c), - logQueries: $logQueries, - ) - ); - } - - // Multi-connection registry; the default connection mirrors DatabasePort. - $container->singleton(DatabaseConnectionManagerContract::class, static function ($c) use ($logQueries): DatabaseConnectionManagerContract { - $manager = new ConnectionManager( - defaultName: 'default', - logger: self::optionalLogger($c), - logQueries: $logQueries, - ); - $manager->register('default', $c->make(DatabaseConfigurationContract::class)); - - return $manager; - }); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // No pipeline hooks or event subscriptions — the module is pure infrastructure. - } - - /** - * Wire the connection-pool-backed DatabasePort. - * - * The ConnectionPool is resolved as an app-lifetime binding when one was - * provided by the bootstrap (preferred — one pool per worker, reused across - * requests). If none is bound, a pool is created lazily as a container - * singleton so the pooled path also works without bootstrap changes. - * - * The PooledDatabaseAdapter itself is request-scoped (bind, not singleton): - * each request borrows one connection and returns it on teardown. - */ - private function registerPooledPort(ModuleContainer $container, bool $logQueries): void - { - if (!$container->has(ConnectionPool::class)) { - $container->singleton(ConnectionPool::class, static function ($c) use ($logQueries): ConnectionPool { - $config = $c->make(DatabaseConfigurationContract::class); - $logger = self::optionalLogger($c); - - $pool = new ConnectionPool( - factory: static fn (): MultiDriverDatabaseAdapter => - new MultiDriverDatabaseAdapter($config, $logger, $logQueries), - config: PoolConfiguration::fromEnvironment(), - driver: $config->driver(), - ); - $pool->warmup(); - - return $pool; - }); - } - - $container->bind(DatabasePort::class, static fn ($c): DatabasePort => - new PooledDatabaseAdapter($c->make(ConnectionPool::class)) - ); - } - - /** - * Resolve the app logger if one is bound; observability is optional here — - * the database must work in a bootstrap that has not wired a LoggerPort. - */ - private static function optionalLogger(mixed $container): ?LoggerPort - { - try { - $logger = $container->make(LoggerPort::class); - - return $logger instanceof LoggerPort ? $logger : null; - } catch (\Throwable) { - return null; - } - } - - private function boolEnv(string $key): bool - { - $value = env($key); - - return $value !== false - && $value !== null - && in_array(strtolower((string) $value), ['1', 'true', 'yes', 'on'], true); - } -} diff --git a/plugins/Database/module.json b/plugins/Database/module.json deleted file mode 100644 index 12465cc..0000000 --- a/plugins/Database/module.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "database", - "version": "2.0.0", - "solves": "database.management", - "type": "module", - "description": "Enterprise multi-driver database module with lazy connections, nested transactions, auto-reconnect, and query observability for MySQL, PostgreSQL, SQLite, and SQL Server", - - "requires": [], - "exposes": [ - "DatabasePort", - "DatabaseConfigurationContract", - "DatabaseConnectionManagerContract" - ], - - "config": [ - { "key": "DB_DRIVER", "type": "string", "required": false }, - { "key": "DB_HOST", "type": "string", "required": false }, - { "key": "DB_PORT", "type": "int", "required": false }, - { "key": "DB_DATABASE", "type": "string", "required": false }, - { "key": "DB_USERNAME", "type": "string", "required": false }, - { "key": "DB_PASSWORD", "type": "string", "required": false }, - { "key": "DB_CHARSET", "type": "string", "required": false }, - { "key": "DB_SSL_MODE", "type": "string", "required": false }, - { "key": "DB_SSL_VERIFY", "type": "bool", "required": false }, - { "key": "DB_SSL_CA", "type": "string", "required": false }, - { "key": "DB_UNIX_SOCKET", "type": "string", "required": false }, - { "key": "DB_ENCRYPT", "type": "bool", "required": false }, - { "key": "DB_TRUST_SERVER_CERT", "type": "bool", "required": false }, - { "key": "DB_ENABLE_QUERY_LOG", "type": "bool", "required": false }, - { "key": "DB_POOL_ENABLED", "type": "bool", "required": false }, - { "key": "DB_POOL_MIN", "type": "int", "required": false }, - { "key": "DB_POOL_MAX", "type": "int", "required": false }, - { "key": "DB_POOL_SIZE", "type": "int", "required": false }, - { "key": "DB_POOL_ACQUIRE_TIMEOUT_MS", "type": "int", "required": false }, - { "key": "DB_POOL_IDLE_TIMEOUT", "type": "int", "required": false }, - { "key": "DB_POOL_MAX_LIFETIME", "type": "int", "required": false }, - { "key": "DB_POOL_VALIDATE", "type": "bool", "required": false } - ] -} diff --git a/plugins/DevTools/Commands/ConfigClearCommand.php b/plugins/DevTools/Commands/ConfigClearCommand.php deleted file mode 100644 index 06fa0ca..0000000 --- a/plugins/DevTools/Commands/ConfigClearCommand.php +++ /dev/null @@ -1,53 +0,0 @@ -name = 'config:clear'; - $this->description = 'Delete the compiled config manifest'; - } - - protected function handle(): int - { - $path = Paths::cache('manifests/config-manifest.php'); - - if (!is_file($path)) { - $this->info('No compiled config manifest — nothing to clear.'); - - return self::SUCCESS; - } - - if (!@unlink($path)) { - $this->error("Could not delete {$path} — check file ownership."); - - return self::FAILURE; - } - - if (function_exists('opcache_invalidate')) { - @opcache_invalidate($path, true); - } - - $this->success('Config manifest cleared. It recompiles on the next boot.'); - - return self::SUCCESS; - } -} diff --git a/plugins/DevTools/Commands/ConfigShowCommand.php b/plugins/DevTools/Commands/ConfigShowCommand.php deleted file mode 100644 index b8d0798..0000000 --- a/plugins/DevTools/Commands/ConfigShowCommand.php +++ /dev/null @@ -1,136 +0,0 @@ -name = 'config:show'; - $this->description = 'Show resolved configuration (group, dotted key, or everything)'; - - $this->addArgument('key', 'Config group or dotted key', required: false); - $this->addOption('json', 'j', 'Output raw JSON'); - } - - protected function handle(): int - { - $items = ManifestReader::readCompiled('config-manifest.php'); - - if ($items === []) { - $this->warning('No config manifest found. Run the app once (or config:cache) to compile it.'); - - return self::SUCCESS; - } - - $config = new Repository($items); - $key = (string) ($this->argument('key') ?? ''); - - if ($key === '') { - return $this->showGroups($items); - } - - if (!$config->has($key)) { - $this->error("Config key [{$key}] is not set."); - - return self::FAILURE; - } - - $value = $config->get($key); - - if ($this->hasOption('json')) { - $this->info((string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - return self::SUCCESS; - } - - if (!is_array($value)) { - $this->info($key . ' = ' . $this->render($value)); - - return self::SUCCESS; - } - - $rows = []; - foreach ($this->flatten($value, $key) as $dotted => $leaf) { - $rows[] = [$dotted, $this->render($leaf)]; - } - - $this->table()->headers(['Key', 'Value'])->rows($rows)->render(); - - return self::SUCCESS; - } - - /** @param array $items */ - private function showGroups(array $items): int - { - if ($this->hasOption('json')) { - $this->info((string) json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - return self::SUCCESS; - } - - $rows = []; - foreach ($items as $group => $values) { - $rows[] = [ - (string) $group, - is_array($values) ? (string) count($this->flatten($values, (string) $group)) : '1', - ]; - } - - $this->table()->headers(['Group', 'Keys'])->rows($rows)->render(); - $this->newLine(); - $this->info(count($items) . ' config group(s). Pass a group or dotted key to drill in.'); - - return self::SUCCESS; - } - - /** - * Flatten nested config to dotted leaves so the table stays readable. - * - * @param array $values - * @return array - */ - private function flatten(array $values, string $prefix): array - { - $flat = []; - foreach ($values as $key => $value) { - $dotted = $prefix . '.' . $key; - if (is_array($value) && $value !== [] && !array_is_list($value)) { - $flat += $this->flatten($value, $dotted); - continue; - } - $flat[$dotted] = $value; - } - - return $flat; - } - - private function render(mixed $value): string - { - return match (true) { - $value === null => 'null', - is_bool($value) => $value ? 'true' : 'false', - is_array($value) => (string) json_encode($value, JSON_UNESCAPED_SLASHES), - default => (string) $value, - }; - } -} diff --git a/plugins/DevTools/Commands/GeneratorCommand.php b/plugins/DevTools/Commands/GeneratorCommand.php deleted file mode 100644 index 21101de..0000000 --- a/plugins/DevTools/Commands/GeneratorCommand.php +++ /dev/null @@ -1,70 +0,0 @@ -snake($value)); - } - - /** - * Write $contents to $path, creating directories as needed. - * Returns false (and reports) if the file exists and --force was not given. - */ - protected function writeFile(string $path, string $contents, bool $force = false): bool - { - if (is_file($path) && !$force) { - $this->warning('Skipped (exists): ' . $this->relative($path)); - return false; - } - - $dir = dirname($path); - if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { - $this->error('Cannot create directory: ' . $dir); - return false; - } - - if (file_put_contents($path, $contents) === false) { - $this->error('Cannot write file: ' . $this->relative($path)); - return false; - } - - $this->success('Created: ' . $this->relative($path)); - return true; - } - - protected function relative(string $path): string - { - $cwd = getcwd() . DIRECTORY_SEPARATOR; - return str_starts_with($path, $cwd) ? substr($path, strlen($cwd)) : $path; - } -} diff --git a/plugins/DevTools/Commands/MakePluginCommand.php b/plugins/DevTools/Commands/MakePluginCommand.php deleted file mode 100644 index e422a94..0000000 --- a/plugins/DevTools/Commands/MakePluginCommand.php +++ /dev/null @@ -1,360 +0,0 @@ -name = 'make:plugin'; - $this->description = 'Scaffold a full GDA plugin (all layers) under plugins/'; - - $this->addArgument('name', 'Plugin name in StudlyCase (e.g. Invoice)'); - $this->addOption('solves', 's', 'Domain the plugin solves (e.g. invoice.generation)', acceptsValue: true); - $this->addOption('force', 'f', 'Overwrite existing files'); - } - - protected function handle(): int - { - $name = (string) ($this->argument('name') ?? ''); - if ($name === '') { - $name = (new TextInput('Plugin name (StudlyCase)')) - ->placeholder('e.g. Invoice') - ->validate(static fn (string $v): ?string => - preg_match('/^[A-Z][A-Za-z0-9]+$/', $v) ? null : 'Use StudlyCase, e.g. Invoice') - ->run(); - } - - $studly = $this->studly($name); - $snake = $this->snake($studly); - $kebab = $this->kebab($studly); - $solves = (string) ($this->option('solves') ?? ($snake . '.management')); - $force = (bool) $this->hasOption('force'); - $root = $this->pluginsRoot() . '/' . $studly; - $ns = 'Plugins\\' . $studly; - - if (is_dir($root) && !$force) { - $this->error("Plugin already exists: plugins/{$studly} (use --force to overwrite files)"); - return self::FAILURE; - } - - $this->section("Scaffolding plugin: {$studly}"); - - foreach ($this->files($studly, $ns, $solves, $kebab) as $rel => $contents) { - $this->writeFile($root . '/' . $rel, $contents, $force); - } - - $this->newLine(); - $this->alertSuccess("Plugin {$studly} created", [ - "Register it: add {$ns}\\Provider::class to a project bootstrap app.php", - "Domain (solves): {$solves}", - ]); - - return self::SUCCESS; - } - - /** - * @return array relativePath => fileContents - */ - private function files(string $studly, string $ns, string $solves, string $kebab): array - { - $contractFqcn = "{$ns}\\API\\Contracts\\{$studly}ServiceContract"; - - return [ - 'module.json' => $this->moduleJson($studly, $ns, $solves, $kebab), - 'Provider.php' => $this->provider($studly, $ns), - "API/Contracts/{$studly}ServiceContract.php" => $this->contract($studly, $ns), - "Application/Services/{$studly}Service.php" => $this->service($studly, $ns), - "Domain/Entities/{$studly}.php" => $this->entity($studly, $ns), - "Infrastructure/Persistence/{$studly}Repository.php" => $this->repository($studly, $ns), - "Infrastructure/Http/{$studly}Controller.php" => $this->controller($studly, $ns), - ]; - } - - private function moduleJson(string $studly, string $ns, string $solves, string $kebab): string - { - $contract = str_replace('\\', '\\\\', "{$ns}\\API\\Contracts\\{$studly}ServiceContract"); - $controller = str_replace('\\', '\\\\', "{$ns}\\Infrastructure\\Http\\{$studly}Controller"); - return <<snake($s)}.management'; } - - /** @return list */ - public function requires(): array { return [DatabasePort::class]; } - - /** @return list */ - public function exposes(): array { return [{$s}ServiceContract::class]; } - - public function register(ModuleContainer \$container): void - { - \$container->bindInternal({$s}Repository::class, static fn(ModuleContainer \$c) => - new {$s}Repository(\$c->make(DatabasePort::class), \$c->make(Identity::class))); - - \$container->bind({$s}ServiceContract::class, static fn(ModuleContainer \$c) => - new {$s}Service( - repository: \$c->make({$s}Repository::class), - transaction: \$c->make(TransactionManager::class), - collector: \$c->make(DomainEventCollector::class), - eventBus: \$c->make(EventBus::class), - identity: \$c->make(Identity::class), - )); - } - - public function boot(HttpPipeline \$http, CliPipeline \$cli, WorkerPipeline \$worker, EventBus \$events): void - { - } - } - - PHP; - } - - private function contract(string $s, string $ns): string - { - return <<> */ - public function all(): array; - - /** @return array */ - public function find(string \$id): array; - } - - PHP; - } - - private function service(string $s, string $ns): string - { - return <<> */ - public function all(): array - { - return \$this->repository->all(); - } - - /** @return array */ - public function find(string \$id): array - { - return \$this->repository->find(\$id); - } - } - - PHP; - } - - private function entity(string $s, string $ns): string - { - return << */ - private array \$domainEvents = []; - - private function __construct( - private readonly string \$id, - ) {} - - public static function create(string \$id): self - { - return new self(\$id); - } - - public static function reconstitute(string \$id): self - { - return new self(\$id); - } - - public function id(): string { return \$this->id; } - - /** @return array */ - public function releaseEvents(): array - { - \$events = \$this->domainEvents; - \$this->domainEvents = []; - return \$events; - } - } - - PHP; - } - - private function repository(string $s, string $ns): string - { - $table = $this->snake($s) . 's'; - return <<> */ - public function all(): array - { - try { - return \$this->db->query( - 'SELECT * FROM {$table} WHERE tenant_id = :tenant', - ['tenant' => \$this->identity->tenantId] - ); - } catch (\\PDOException \$e) { - throw new RepositoryException('Failed to list {$table}', layer: 'repository.{$this->snake($s)}', previous: \$e); - } - } - - /** @return array */ - public function find(string \$id): array - { - try { - \$row = \$this->db->queryOne( - 'SELECT * FROM {$table} WHERE id = :id AND tenant_id = :tenant', - ['id' => \$id, 'tenant' => \$this->identity->tenantId] - ); - } catch (\\PDOException \$e) { - throw new RepositoryException("Failed to find {$this->snake($s)} [\$id]", layer: 'repository.{$this->snake($s)}', previous: \$e); - } - - if (\$row === null) { - throw new RepositoryException("{$s} [\$id] not found", layer: 'repository.{$this->snake($s)}'); - } - - return \$row; - } - } - - PHP; - } - - private function controller(string $s, string $ns): string - { - return <<service->all()); - } - - public function show(Request \$request, string \$id): Response - { - return Response::json(\$this->service->find(\$id)); - } - } - - PHP; - } -} diff --git a/plugins/DevTools/Commands/MakeServiceCommand.php b/plugins/DevTools/Commands/MakeServiceCommand.php deleted file mode 100644 index 929d4b3..0000000 --- a/plugins/DevTools/Commands/MakeServiceCommand.php +++ /dev/null @@ -1,64 +0,0 @@ - plugins/Invoice/Application/Services/RefundService.php - */ -final class MakeServiceCommand extends GeneratorCommand -{ - protected function configure(): void - { - $this->name = 'make:service'; - $this->description = 'Generate an Application service inside a plugin'; - - $this->addArgument('plugin', 'Target plugin name (StudlyCase)'); - $this->addArgument('name', 'Service name without the "Service" suffix'); - $this->addOption('force', 'f', 'Overwrite if it exists'); - } - - protected function handle(): int - { - $plugin = $this->studly((string) ($this->argument('plugin') ?? '')); - $name = (string) ($this->argument('name') ?? ''); - - if ($plugin === '') { - $this->error('A target plugin name is required.'); - return self::FAILURE; - } - if ($name === '') { - $name = (new TextInput('Service name'))->placeholder('e.g. Refund')->run(); - } - - $studly = $this->studly($name); - $ns = "Plugins\\{$plugin}\\Application\\Services"; - $path = $this->pluginsRoot() . "/{$plugin}/Application/Services/{$studly}Service.php"; - - $stub = <<writeFile($path, $stub, (bool) $this->hasOption('force')) - ? self::SUCCESS - : self::FAILURE; - } -} diff --git a/plugins/DevTools/Commands/ModuleInfoCommand.php b/plugins/DevTools/Commands/ModuleInfoCommand.php deleted file mode 100644 index 9a93c5e..0000000 --- a/plugins/DevTools/Commands/ModuleInfoCommand.php +++ /dev/null @@ -1,109 +0,0 @@ - (e.g. module:info auth) - */ -final class ModuleInfoCommand extends AbstractCommand -{ - protected function configure(): void - { - $this->name = 'module:info'; - $this->description = 'Show a module/plugin\'s manifest details'; - - $this->addArgument('name', 'Module name as declared in module.json', required: true); - $this->addOption('json', 'j', 'Output raw JSON'); - } - - protected function handle(): int - { - $name = (string) ($this->argument('name') ?? ''); - if ($name === '') { - $this->error('A module name is required.'); - return self::FAILURE; - } - - $manifest = $this->find($name); - if ($manifest === null) { - $this->error("No module named '{$name}' found under plugins/ or modules/."); - return self::FAILURE; - } - - [$data, $location] = $manifest; - - if ($this->hasOption('json')) { - $this->info(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - return self::SUCCESS; - } - - $this->section("Module: {$data['name']}"); - $this->info('Location : ' . $location); - $this->info('Version : ' . ($data['version'] ?? '—')); - $this->info('Solves : ' . ($data['solves'] ?? '—')); - $this->info('Type : ' . ($data['type'] ?? 'module')); - $this->info('Requires : ' . $this->joinList($data['requires'] ?? [])); - $this->info('Exposes : ' . $this->joinList($data['exposes'] ?? [])); - $this->info('Emits : ' . $this->joinList($data['emits'] ?? [])); - $this->info('Config : ' . $this->joinConfig($data['config'] ?? [])); - - $routes = $data['routes'] ?? []; - if ($routes !== []) { - $this->newLine(); - $this->table() - ->headers(['Method', 'Path', 'Handler']) - ->rows(array_map( - static fn(array $r): array => [ - strtoupper((string) ($r['method'] ?? 'GET')), - (string) ($r['path'] ?? ''), - (string) ($r['handler'] ?? ''), - ], - $routes, - )) - ->render(); - } - - return self::SUCCESS; - } - - /** - * @return array{0:array,1:string}|null - */ - private function find(string $name): ?array - { - foreach (['plugins', 'modules'] as $base) { - foreach (glob(getcwd() . "/{$base}/*/module.json") ?: [] as $file) { - $data = json_decode((string) file_get_contents($file), true); - if (is_array($data) && ($data['name'] ?? null) === $name) { - return [$data, $base . '/' . basename(dirname($file))]; - } - } - } - return null; - } - - /** @param list $list */ - private function joinList(array $list): string - { - return $list === [] ? '—' : implode(', ', array_map('strval', $list)); - } - - /** @param list $config */ - private function joinConfig(array $config): string - { - if ($config === []) { - return '—'; - } - $keys = array_map( - static fn($c) => is_array($c) ? (string) ($c['key'] ?? '?') : (string) $c, - $config, - ); - return implode(', ', $keys); - } -} diff --git a/plugins/DevTools/Commands/ModuleListCommand.php b/plugins/DevTools/Commands/ModuleListCommand.php deleted file mode 100644 index 5fdc182..0000000 --- a/plugins/DevTools/Commands/ModuleListCommand.php +++ /dev/null @@ -1,87 +0,0 @@ -name = 'module:list'; - $this->description = 'List all discovered modules/plugins from their module.json'; - - $this->addOption('json', 'j', 'Output raw JSON instead of a table'); - } - - protected function handle(): int - { - $modules = $this->discover(); - - if ($modules === []) { - $this->warning('No module.json files found under plugins/ or modules/.'); - return self::SUCCESS; - } - - if ($this->hasOption('json')) { - $this->info(json_encode(array_values($modules), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - return self::SUCCESS; - } - - $rows = []; - foreach ($modules as $m) { - $rows[] = [ - $m['name'], - $m['solves'], - $m['type'], - (string) $m['routes'], - (string) $m['exposes'], - $m['location'], - ]; - } - - $this->table() - ->headers(['Name', 'Solves', 'Type', 'Routes', 'Exposes', 'Location']) - ->rows($rows) - ->render(); - - $this->newLine(); - $this->info(count($modules) . ' module(s) discovered.'); - - return self::SUCCESS; - } - - /** - * @return array - */ - private function discover(): array - { - $found = []; - foreach (['plugins', 'modules'] as $base) { - foreach (glob(getcwd() . "/{$base}/*/module.json") ?: [] as $file) { - $data = json_decode((string) file_get_contents($file), true); - if (!is_array($data) || !isset($data['name'])) { - continue; - } - $found[(string) $data['name']] = [ - 'name' => (string) $data['name'], - 'solves' => (string) ($data['solves'] ?? '—'), - 'type' => (string) ($data['type'] ?? 'module'), - 'routes' => count($data['routes'] ?? []), - 'exposes' => count($data['exposes'] ?? []), - 'location' => $base . '/' . basename(dirname($file)), - ]; - } - } - ksort($found); - return $found; - } -} diff --git a/plugins/DevTools/Commands/ProjectListCommand.php b/plugins/DevTools/Commands/ProjectListCommand.php deleted file mode 100644 index b9d1ee1..0000000 --- a/plugins/DevTools/Commands/ProjectListCommand.php +++ /dev/null @@ -1,108 +0,0 @@ -/proj.json, enriched with the - * domains declared in projects/projects.json. - * - * Usage: project:list [--json] - */ -final class ProjectListCommand extends AbstractCommand -{ - protected function configure(): void - { - $this->name = 'project:list'; - $this->description = 'List registered projects and their domains'; - - $this->addOption('json', 'j', 'Output raw JSON'); - } - - protected function handle(): int - { - $projects = $this->discover(); - - if ($projects === []) { - $this->warning('No projects found under projects/.'); - return self::SUCCESS; - } - - if ($this->hasOption('json')) { - $this->info(json_encode(array_values($projects), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - return self::SUCCESS; - } - - $rows = array_map( - static fn(array $p): array => [ - $p['name'], - $p['version'], - $p['domains'] === '' ? '—' : $p['domains'], - $p['features'] === '' ? '—' : $p['features'], - ], - array_values($projects), - ); - - $this->table() - ->headers(['Name', 'Version', 'Domains', 'Features']) - ->rows($rows) - ->render(); - - $this->newLine(); - $this->info(count($projects) . ' project(s).'); - - return self::SUCCESS; - } - - /** - * @return array - */ - private function discover(): array - { - $root = getcwd() . '/projects'; - $domainMap = $this->loadDomainMap($root . '/projects.json'); - - $projects = []; - foreach (glob($root . '/*/proj.json') ?: [] as $file) { - $data = json_decode((string) file_get_contents($file), true); - if (!is_array($data) || !isset($data['name'])) { - continue; - } - $name = (string) $data['name']; - $projects[$name] = [ - 'name' => $name, - 'version' => (string) ($data['version'] ?? '—'), - 'domains' => implode(', ', $domainMap[$name] ?? []), - 'features' => implode(', ', array_map('strval', $data['features'] ?? [])), - ]; - } - - ksort($projects); - return $projects; - } - - /** - * @return array> - */ - private function loadDomainMap(string $path): array - { - if (!is_file($path)) { - return []; - } - $data = json_decode((string) file_get_contents($path), true); - if (!is_array($data)) { - return []; - } - - $map = []; - foreach ($data as $name => $entry) { - if (is_array($entry) && isset($entry['domains']) && is_array($entry['domains'])) { - $map[(string) $name] = array_map('strval', $entry['domains']); - } - } - return $map; - } -} diff --git a/plugins/DevTools/Commands/RoutesListCommand.php b/plugins/DevTools/Commands/RoutesListCommand.php deleted file mode 100644 index e8d241f..0000000 --- a/plugins/DevTools/Commands/RoutesListCommand.php +++ /dev/null @@ -1,115 +0,0 @@ -name = 'routes:list'; - $this->description = 'List all routes declared in module.json across plugins/ and modules/'; - - $this->addOption('json', 'j', 'Output raw JSON'); - $this->addOption('method', 'm', 'Filter by HTTP method (GET, POST, ...)', acceptsValue: true); - } - - protected function handle(): int - { - $filterMethod = strtoupper((string) ($this->option('method') ?? '')); - $routes = $this->collect($filterMethod); - - if ($routes === []) { - $this->warning('No routes declared in any module.json.'); - return self::SUCCESS; - } - - if ($this->hasOption('json')) { - $this->info(json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - return self::SUCCESS; - } - - $rows = array_map( - static fn(array $r): array => [$r['method'], $r['path'], $r['handler'], $r['module']], - $routes, - ); - - $this->table() - ->headers(['Method', 'Path', 'Handler', 'Module']) - ->rows($rows) - ->render(); - - $this->newLine(); - $this->reportCollisions($routes); - $this->info(count($routes) . ' route(s) total.'); - - return self::SUCCESS; - } - - /** - * @return list - */ - private function collect(string $filterMethod): array - { - $routes = []; - foreach (['plugins', 'modules'] as $base) { - foreach (glob(getcwd() . "/{$base}/*/module.json") ?: [] as $file) { - $data = json_decode((string) file_get_contents($file), true); - if (!is_array($data)) { - continue; - } - $module = (string) ($data['name'] ?? basename(dirname($file))); - foreach ($data['routes'] ?? [] as $route) { - $method = strtoupper((string) ($route['method'] ?? 'GET')); - if ($filterMethod !== '' && $method !== $filterMethod) { - continue; - } - $routes[] = [ - 'method' => $method, - 'path' => (string) ($route['path'] ?? ''), - 'handler' => (string) ($route['handler'] ?? ''), - 'module' => $module, - ]; - } - } - } - - usort($routes, static fn(array $a, array $b) => [$a['path'], $a['method']] <=> [$b['path'], $b['method']]); - return $routes; - } - - /** @param list $routes */ - private function reportCollisions(array $routes): void - { - $seen = []; - $collisions = []; - foreach ($routes as $r) { - $key = $r['method'] . ' ' . $r['path']; - if (isset($seen[$key])) { - $collisions[$key][] = $r['module']; - } else { - $seen[$key] = $r['module']; - } - } - - if ($collisions === []) { - return; - } - - $this->warning('Route collisions detected (same method+path in multiple modules):'); - foreach ($collisions as $key => $modules) { - $this->warning(" {$key} -> " . $seen[$key] . ', ' . implode(', ', $modules)); - } - $this->newLine(); - } -} diff --git a/plugins/DevTools/Provider.php b/plugins/DevTools/Provider.php deleted file mode 100644 index ea516fa..0000000 --- a/plugins/DevTools/Provider.php +++ /dev/null @@ -1,63 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return []; - } - - public function register(ModuleContainer $container): void - { - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - $cli->command(MakePluginCommand::class); - $cli->command(MakeServiceCommand::class); - $cli->command(ModuleListCommand::class); - $cli->command(ModuleInfoCommand::class); - $cli->command(RoutesListCommand::class); - $cli->command(ProjectListCommand::class); - $cli->command(ConfigShowCommand::class); - $cli->command(ConfigClearCommand::class); - } -} diff --git a/plugins/DevTools/module.json b/plugins/DevTools/module.json deleted file mode 100644 index e10208c..0000000 --- a/plugins/DevTools/module.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "dev-tools", - "version": "1.0.0", - "solves": "dev.tooling", - "type": "module", - - "requires": [], - "exposes": [], - - "routes": [], - "emits": [], - "listens": [], - - "config": [] -} diff --git a/plugins/Edge/API/Contracts/EdgeServiceContract.php b/plugins/Edge/API/Contracts/EdgeServiceContract.php deleted file mode 100644 index c632273..0000000 --- a/plugins/Edge/API/Contracts/EdgeServiceContract.php +++ /dev/null @@ -1,76 +0,0 @@ -} - */ - public function phpFpm(): array; - - /** - * Detect + collect sites + render — WITHOUT touching the filesystem. - * $all=false (default) scopes to the CURRENT project; true = every project. - * - * TLS overrides (null = use config): $tlsMode is one of ssl|none|both, - * $sslCert/$sslKey override the certificate paths. $appEnv overrides APP_ENV - * (local|development|production), which is what the cache profile derives from. - * - * $force pins the strategy explicitly (null = auto-detect): 'nginx-only' - * serves via nginx with NO Apache fallback; 'apache-only' serves via Apache - * with no fallback — regardless of what else is running on the host. - */ - public function plan(bool $all = false, ?string $tlsMode = null, ?string $sslCert = null, ?string $sslKey = null, ?string $appEnv = null, ?string $force = null): EdgePlan; - - /** - * Write the rendered config, sync local domains to /etc/hosts, then - * (optionally) validate + reload the server. - * - * @return array{ - * ok: bool, strategy: string, path?: string, sites?: int, - * dry_run?: bool, contents?: string, steps?: list, - * hosts?: array|null, message?: string - * } - * - * TLS overrides (null = use config): $tlsMode is one of ssl|none|both, - * $sslCert/$sslKey override the certificate paths. $force pins the strategy - * ('nginx-only' | 'apache-only', null = auto-detect) with no fallback. - */ - public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null, bool $all = false, ?string $tlsMode = null, ?string $sslCert = null, ?string $sslKey = null, ?string $appEnv = null, ?string $force = null): array; - - /** - * Render process-manager units (systemd | supervisor) for the OpenSwoole - * projects in scope. PHP-FPM projects yield nothing — php-fpm supervises them. - * - * @return array unit/program name => file contents - */ - public function serviceUnits(string $format = 'systemd', bool $all = false, ?string $appEnv = null, string $user = 'www-data'): array; - - /** - * Sync LOCAL domains (.local / .test / …) into /etc/hosts (pointing at the - * loopback), or remove the managed block with $remove. $all=false (default) - * scopes to the current project. - * - * DEV ONLY: refuses unless the launcher ran with `--dev` (HKM_DEV=1), or - * $force is passed — a live server uses DNS, not /etc/hosts. A hostname - * already mapped elsewhere in the file is skipped, never duplicated. - * - * @return array{ok: bool, changed?: bool, dry_run?: bool, path: string, count: int, skipped?: list, block?: string, message?: string} - */ - public function syncHosts(bool $remove = false, bool $dryRun = false, bool $all = false, bool $force = false): array; -} diff --git a/plugins/Edge/Application/EdgeService.php b/plugins/Edge/Application/EdgeService.php deleted file mode 100644 index bcf101f..0000000 --- a/plugins/Edge/Application/EdgeService.php +++ /dev/null @@ -1,304 +0,0 @@ - unit/program name => file contents - */ - public function serviceUnits(string $format = 'systemd', bool $all = false, ?string $appEnv = null, string $user = 'www-data'): array - { - $env = $appEnv ?? (string) edge_config('app_env', 'production'); - $out = []; - foreach ($this->sites->sites($all, $env) as $site) { - if (!$this->services->supports($site)) { - continue; - } - $name = $this->services->unitName($site); - $out[$name] = $format === 'supervisor' - ? $this->services->supervisor($site, $user) - : $this->services->systemd($site, $user, $user); - } - - return $out; - } - - public function detect(): ServerStack - { - return $this->probe->detect(); - } - - public function phpFpm(): array - { - return [ - 'version' => $this->probe->phpCliVersion(), - 'socket' => $this->probe->phpFpmSocket(), - 'active' => $this->probe->phpFpmActive(), - ]; - } - - public function plan(bool $all = false, ?string $tlsMode = null, ?string $sslCert = null, ?string $sslKey = null, ?string $appEnv = null, ?string $force = null): EdgePlan - { - $stack = $this->probe->detect(); - $strategy = $stack->strategy($this->forcedStrategy($force)); - - // When both servers run and nginx already has an SNI stream splitter, - // reuse it (default) rather than writing a second `stream {}` block. A - // forced single-server strategy never renders a stream block at all. - $reuseStream = $strategy === Strategy::NginxStream - && (bool) edge_config('reuse_stream', true) - && $stack->nginxHasStreamConfig; - - // The application environment is chosen EXPLICITLY (CLI flag, else the - // configured/exported APP_ENV) and is what the cache profile derives from - // — never the kernel mode. Keeps kernel selection and app env independent. - $env = $appEnv ?? (string) edge_config('app_env', 'production'); - $profile = CacheProfile::fromAppEnv($env); - - // Default: ONLY the current project. --all renders every registered one. - // Public domains → server config; local (.local/.test) → /etc/hosts. - $sites = $this->sites->sites($all, $env); - - [$path, $body] = $this->renderer->render($strategy, $sites, $this->resolveTls($tlsMode, $sslCert, $sslKey), $stack, $profile, $reuseStream); - - return new EdgePlan($stack, $strategy, $sites, $this->sites->localDomains($all), $path, $body, $reuseStream); - } - - /** - * Resolve an EXPLICIT strategy override into a Strategy, or null for - * auto-detection. Accepts the CLI value ($force) first, then the - * EDGE_FORCE_STRATEGY config default. `nginx-only` and `apache-only` pin a - * single server with NO fallback; anything else means "auto-detect". - */ - private function forcedStrategy(?string $force): ?Strategy - { - $value = strtolower(trim($force ?? (string) edge_config('force_strategy', ''))); - - return match ($value) { - 'nginx-only', 'nginx' => Strategy::NginxOnly, - 'apache-only', 'apache' => Strategy::ApacheOnly, - default => null, - }; - } - - /** - * Merge the CLI's TLS overrides with the config defaults into a concrete - * TlsConfig. An unknown/empty mode falls back to the configured default, and - * that to `ssl`. cert/key default to the config's ssl.* paths. - */ - private function resolveTls(?string $tlsMode, ?string $sslCert, ?string $sslKey): TlsConfig - { - $mode = TlsMode::tryFrom((string) ($tlsMode ?? '')) - ?? TlsMode::tryFrom((string) edge_config('tls.mode', 'ssl')) - ?? TlsMode::Ssl; - - return new TlsConfig( - $mode, - (string) ($sslCert ?? edge_config('ssl.cert')), - (string) ($sslKey ?? edge_config('ssl.key')), - ); - } - - /** - * /etc/hosts is a DEVELOPER-machine concern (local .local/.test domains) — - * a live server resolves its public domains through DNS. So this refuses to - * run unless the launcher marked the invocation as dev (`hkm … --dev`, - * which exports HKM_DEV=1), unless explicitly forced. - */ - public function syncHosts(bool $remove = false, bool $dryRun = false, bool $all = false, bool $force = false): array - { - if (!$force && !$this->isDev()) { - return [ - 'ok' => false, - 'path' => (string) edge_config('hosts.path', '/etc/hosts'), - 'count' => 0, - 'message' => 'refusing to touch the hosts file outside dev mode — run with `--dev` (or pass --force). ' - . 'On a live server public domains resolve via DNS, not /etc/hosts.', - ]; - } - - return $this->hosts->sync( - domains: $this->sites->localDomains($all), - ip: (string) edge_config('hosts.ip', '127.0.0.1'), - path: (string) edge_config('hosts.path', '/etc/hosts'), - remove: $remove, - dryRun: $dryRun, - ); - } - - /** Did the launcher run us in dev mode (`--dev` exports HKM_DEV=1)? */ - private function isDev(): bool - { - return filter_var(env('HKM_DEV', 'false'), FILTER_VALIDATE_BOOL); - } - - public function apply(bool $reload = true, bool $dryRun = false, ?bool $manageHosts = null, bool $all = false, ?string $tlsMode = null, ?string $sslCert = null, ?string $sslKey = null, ?string $appEnv = null, ?string $force = null): array - { - $plan = $this->plan($all, $tlsMode, $sslCert, $sslKey, $appEnv, $force); - - // 1. Local domains → /etc/hosts. DEV ONLY: a live server resolves its - // public domains via DNS, so outside dev we silently skip this step - // rather than touching the machine's hosts file. - $hosts = null; - if (($manageHosts ?? (bool) edge_config('manage_hosts', true)) && $this->isDev()) { - $hosts = $this->syncHosts(dryRun: $dryRun, all: $all); - } - - if ($plan->strategy === Strategy::None) { - return [ - 'ok' => ($hosts['ok'] ?? true) === true, - 'strategy' => Strategy::None->value, - 'hosts' => $hosts, - 'message' => 'No active web server detected — only local hosts were synced.', - ]; - } - - if ($dryRun) { - return [ - 'ok' => true, - 'dry_run' => true, - 'strategy' => $plan->strategy->value, - 'path' => $plan->targetPath, - 'sites' => \count($plan->sites), - 'contents' => $plan->contents, - 'hosts' => $hosts, - 'stream' => $this->mergeExistingStream($plan, dryRun: true), - ]; - } - - // 2. Write the server config atomically (temp file + rename) so a live - // include never sees a half-written file. - $dir = dirname($plan->targetPath); - if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { - return ['ok' => false, 'strategy' => $plan->strategy->value, 'hosts' => $hosts, 'message' => "Cannot create directory {$dir}"]; - } - $tmp = $plan->targetPath . '.tmp'; - if (@file_put_contents($tmp, $plan->contents) === false || !@rename($tmp, $plan->targetPath)) { - @unlink($tmp); - return ['ok' => false, 'strategy' => $plan->strategy->value, 'hosts' => $hosts, 'message' => "Failed to write {$plan->targetPath}"]; - } - - $siteCount = \count($plan->sites); - $steps = ["wrote {$plan->targetPath} ({$siteCount} project site(s))"]; - - // 3. Reusing an existing SNI splitter → merge our domains into ITS map, - // editing that host file (e.g. nginx.conf) in place. Fail loudly: a - // write we cannot complete (usually a missing sudo) must not look ok. - $stream = $this->mergeExistingStream($plan, dryRun: false); - if ($stream !== null) { - if (($stream['ok'] ?? false) !== true) { - return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'hosts' => $hosts, 'stream' => $stream, 'message' => 'stream map update failed: ' . ($stream['message'] ?? 'unknown error')]; - } - $added = \count($stream['added'] ?? []); - $steps[] = ($stream['changed'] ?? false) - ? "stream: merged {$added} domain(s) into {$stream['file']}" - : "stream: {$stream['file']} already current"; - } - - if ($reload) { - $isApache = $plan->strategy === Strategy::ApacheOnly; - $testCmd = (string) edge_config($isApache ? 'commands.apache_test' : 'commands.nginx_test'); - $reloadCmd = (string) edge_config($isApache ? 'commands.apache_reload' : 'commands.nginx_reload'); - - [$tc, $tout] = $this->probe->run($testCmd); - $steps[] = "test: {$testCmd} → " . ($tc === 0 ? 'ok' : 'FAILED'); - if ($tc !== 0) { - return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'hosts' => $hosts, 'message' => trim($tout)]; - } - - [$rc, $rout] = $this->probe->run($reloadCmd); - $steps[] = "reload: {$reloadCmd} → " . ($rc === 0 ? 'ok' : 'FAILED'); - if ($rc !== 0) { - return ['ok' => false, 'strategy' => $plan->strategy->value, 'path' => $plan->targetPath, 'steps' => $steps, 'hosts' => $hosts, 'message' => trim($rout)]; - } - } - - return [ - 'ok' => true, - 'strategy' => $plan->strategy->value, - 'path' => $plan->targetPath, - 'sites' => \count($plan->sites), - 'steps' => $steps, - 'hosts' => $hosts, - 'stream' => $stream, - ]; - } - - /** - * When the plan reuses an existing nginx SNI splitter, merge the plan's public - * domains into that host file's `map $ssl_preread_server_name` in place - * (pointing them at the nginx backend). Returns null when there is nothing to - * merge (not a reuse plan, or the splitter file could not be located). - * - * @return array|null - */ - private function mergeExistingStream(EdgePlan $plan, bool $dryRun): ?array - { - if (!$plan->reuseStream) { - return null; - } - $file = $this->probe->nginxStreamConfigFile((string) edge_config('paths.stream', '')); - if ($file === null) { - return null; - } - - return $this->streamWriter->merge( - file: $file, - domains: $this->planPublicDomains($plan), - backend: (string) edge_config('stream_backend', 'nginx_backend'), - dryRun: $dryRun, - ); - } - - /** - * Every public domain across the plan's sites (deduplicated, order-preserved) - * — the hostnames that must route SNI → nginx. - * - * @return list - */ - private function planPublicDomains(EdgePlan $plan): array - { - $domains = []; - foreach ($plan->sites as $site) { - foreach ($site->publicDomains as $d) { - $domains[$d] = true; - } - } - - return array_keys($domains); - } -} diff --git a/plugins/Edge/Domain/CacheProfile.php b/plugins/Edge/Domain/CacheProfile.php deleted file mode 100644 index 63f9995..0000000 --- a/plugins/Edge/Domain/CacheProfile.php +++ /dev/null @@ -1,55 +0,0 @@ - "# HKM Edge cache profile: DEVELOPMENT\n" - . "# Static assets are not cached to prevent stale asset issues.\n", - self::Production => "# HKM Edge cache profile: PRODUCTION\n" - . "# Fingerprinted assets use immutable long-term caching.\n", - }; - } -} diff --git a/plugins/Edge/Domain/EdgePlan.php b/plugins/Edge/Domain/EdgePlan.php deleted file mode 100644 index 7b881cd..0000000 --- a/plugins/Edge/Domain/EdgePlan.php +++ /dev/null @@ -1,29 +0,0 @@ - $sites per-project sites in the server config - * @param list $localDomains dev-only domains (.local / .test / …) → /etc/hosts - */ - public function __construct( - public ServerStack $stack, - public Strategy $strategy, - public array $sites, - public array $localDomains, - public string $targetPath, - public string $contents, - // NginxStream only: an existing nginx stream splitter was found and is - // being reused, so this file emits ONLY the internal backend vhosts. - public bool $reuseStream = false, - ) {} -} diff --git a/plugins/Edge/Domain/ServeModel.php b/plugins/Edge/Domain/ServeModel.php deleted file mode 100644 index f3291ba..0000000 --- a/plugins/Edge/Domain/ServeModel.php +++ /dev/null @@ -1,62 +0,0 @@ -/app/public` via PHP-FPM (fastcgi), - * passing the run env as fastcgi_param / SetEnv. - * - Swoole : the project runs its own OpenSwoole HTTP server; nginx acts as a - * reverse proxy to that upstream (env lives in the Swoole process) - * while still serving static assets straight off disk. - * - * Accepted spellings (case/spacing insensitive): `php-fpm`, `php_fpm`, `fpm` - * for PHP-FPM and `openswoole`, `open-swoole`, `swoole` for OpenSwoole. The - * stored values stay `fpm`/`swoole` so existing proj.json / EDGE_SERVE_MODEL - * settings keep working unchanged. - */ -enum ServeModel: string -{ - case Fpm = 'fpm'; - case Swoole = 'swoole'; - - /** Alias → canonical value. Lets `runtime=php-fpm|openswoole` be used too. */ - private const ALIASES = [ - 'php-fpm' => 'fpm', - 'php_fpm' => 'fpm', - 'phpfpm' => 'fpm', - 'fpm' => 'fpm', - 'openswoole' => 'swoole', - 'open-swoole' => 'swoole', - 'open_swoole' => 'swoole', - 'swoole' => 'swoole', - ]; - - /** Parse any accepted spelling; unknown input falls back to $default. */ - public static function from_(string $value, self $default = self::Fpm): self - { - $key = strtolower(trim($value)); - - return self::tryFrom(self::ALIASES[$key] ?? $key) ?? $default; - } - - /** Human label used in the generated config banner. */ - public function label(): string - { - return match ($this) { - self::Fpm => 'PHP-FPM', - self::Swoole => 'OpenSwoole', - }; - } - - /** The `# HKM Edge runtime: …` banner written at the top of each vhost. */ - public function banner(): string - { - return match ($this) { - self::Fpm => "# HKM Edge runtime: PHP-FPM\n", - self::Swoole => "# HKM Edge runtime: OpenSwoole\n# Nginx is acting as reverse proxy\n", - }; - } -} diff --git a/plugins/Edge/Domain/ServerStack.php b/plugins/Edge/Domain/ServerStack.php deleted file mode 100644 index 5a9786e..0000000 --- a/plugins/Edge/Domain/ServerStack.php +++ /dev/null @@ -1,91 +0,0 @@ - $apacheModules loaded Apache module short names (no - * `_module` suffix), e.g. ['headers','deflate','brotli','ssl']. - * Empty means "could not probe" — treated as unknown, not absent. - */ - public function __construct( - public bool $nginxInstalled, - public bool $nginxActive, - public bool $nginxHasStream, - public bool $apacheInstalled, - public bool $apacheActive, - public bool $nginxHasBrotli = false, - public array $apacheModules = [], - // The RUNNING nginx already declares an SNI stream splitter (a `stream {}` - // block using ssl_preread) in a file Edge does not manage. When true Edge - // reuses it instead of emitting a second, conflicting splitter. - public bool $nginxHasStreamConfig = false, - ) {} - - /** - * Is an Apache module loaded? Accepts a short name (`headers`) or the full - * `headers_module`. When the module list is empty (probe unavailable) this - * returns true so features aren't silently dropped — a genuinely missing - * module is then caught by `apachectl configtest` before reload. - */ - public function apacheHasModule(string $name): bool - { - if ($this->apacheModules === []) { - return true; // unknown → assume present; configtest is the backstop - } - $short = str_ends_with($name, '_module') ? substr($name, 0, -7) : $name; - - return in_array($short, $this->apacheModules, true); - } - - /** - * Pick the routing strategy. A non-null $force is an EXPLICIT operator - * override (`--nginx-only` / `--apache-only`, or EDGE_FORCE_STRATEGY): the - * chosen single server is used verbatim with NO fallback, regardless of what - * else is running. Auto-detection (the default) is: - * both active → stream if nginx has it, else nginx-only (nginx is front) - * nginx only → nginx-only - * apache only → apache-only - * neither → none - */ - public function strategy(?Strategy $force = null): Strategy - { - if ($force === Strategy::NginxOnly || $force === Strategy::ApacheOnly) { - return $force; - } - if ($this->nginxActive && $this->apacheActive) { - return $this->nginxHasStream ? Strategy::NginxStream : Strategy::NginxOnly; - } - if ($this->nginxActive) { - return Strategy::NginxOnly; - } - if ($this->apacheActive) { - return Strategy::ApacheOnly; - } - return Strategy::None; - } - - /** @return array */ - public function toArray(): array - { - return [ - 'nginx_installed' => $this->nginxInstalled, - 'nginx_active' => $this->nginxActive, - 'nginx_has_stream' => $this->nginxHasStream, - 'nginx_has_stream_cfg' => $this->nginxHasStreamConfig, - 'nginx_has_brotli' => $this->nginxHasBrotli, - 'apache_installed' => $this->apacheInstalled, - 'apache_active' => $this->apacheActive, - 'strategy' => $this->strategy()->value, - ]; - } -} diff --git a/plugins/Edge/Domain/Site.php b/plugins/Edge/Domain/Site.php deleted file mode 100644 index 3d4f8a7..0000000 --- a/plugins/Edge/Domain/Site.php +++ /dev/null @@ -1,57 +0,0 @@ -/app/public`), how it's served (FPM vs Swoole + the - * upstream), and the run-env that must be injected into its vhost so the project - * boots (APP_ENV, HKM_USERDATA_DIR, PSP_GLOBAL_AUTOLOAD, HKM_KERNEL_HOME, …). - * - * Local (.local/.test) domains ride along on the owning site but are NOT put in - * the server config — they go to /etc/hosts. - */ -final readonly class Site -{ - /** - * @param list $publicDomains server-facing hostnames - * @param list $localDomains dev-only hostnames (→ /etc/hosts) - * @param array $env run-env injected into the vhost - */ - public function __construct( - public string $name, - public string $docroot, // /app/public - public array $publicDomains, - public array $localDomains, - public ServeModel $model, - public string $upstream, // fpm: fastcgi socket/addr · swoole: host:port - public array $env, - /** OpenSwoole settings (ws/health routes, service unit); null for FPM. */ - public ?SwooleOptions $swoole = null, - /** Project root (PROJECT_ROOT). Every other path derives from it. */ - public string $root = '', - ) {} - - /** - * PROJECT_ROOT/app/public — the nginx `root`, where static assets live. - * Falls back to the explicit docroot for sites built without a project root. - */ - public function publicRoot(): string - { - return $this->root !== '' ? $this->root . '/app/public' : $this->docroot; - } - - /** PROJECT_ROOT/app/swoole — the OpenSwoole runtime directory. */ - public function swooleRoot(): string - { - return ($this->root !== '' ? $this->root : dirname($this->docroot, 2)) . '/app/swoole'; - } - - /** Does this site have anything to put in the server config? */ - public function servesPublic(): bool - { - return $this->publicDomains !== [] && $this->docroot !== ''; - } -} diff --git a/plugins/Edge/Domain/Strategy.php b/plugins/Edge/Domain/Strategy.php deleted file mode 100644 index 3c914a1..0000000 --- a/plugins/Edge/Domain/Strategy.php +++ /dev/null @@ -1,35 +0,0 @@ - 'nginx SNI stream splitter (nginx + Apache fallback)', - self::NginxOnly => 'nginx-only reverse proxy (no stream)', - self::ApacheOnly => 'Apache-only SSL VirtualHost', - self::None => 'no active web server', - }; - } -} diff --git a/plugins/Edge/Domain/SwooleOptions.php b/plugins/Edge/Domain/SwooleOptions.php deleted file mode 100644 index 7a302c0..0000000 --- a/plugins/Edge/Domain/SwooleOptions.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ - public array $extraServers = [], - /** Upstream balancing directive: least_conn | ip_hash | random | '' (round-robin). */ - public string $balance = 'least_conn', - public int $maxFails = 3, - public string $failTimeout = '10s', - /** Idle upstream connections kept per worker. 0 disables the pool. */ - public int $keepalive = 64, - public string $keepaliveTimeout = '', - public int $keepaliveRequests = 0, - ) {} - - /** `host:port` — the primary backend. */ - public function upstream(): string - { - return "{$this->host}:{$this->port}"; - } - - /** - * Every backend in the pool: the primary first, then any extras (de-duped). - * @return list - */ - public function servers(): array - { - return array_values(array_unique([$this->upstream(), ...$this->extraServers])); - } -} diff --git a/plugins/Edge/Domain/TlsConfig.php b/plugins/Edge/Domain/TlsConfig.php deleted file mode 100644 index 0d612b8..0000000 --- a/plugins/Edge/Domain/TlsConfig.php +++ /dev/null @@ -1,26 +0,0 @@ -cert, $this->key); - } -} diff --git a/plugins/Edge/Domain/TlsMode.php b/plugins/Edge/Domain/TlsMode.php deleted file mode 100644 index 570e5db..0000000 --- a/plugins/Edge/Domain/TlsMode.php +++ /dev/null @@ -1,40 +0,0 @@ - 'HTTPS only (:443)', - self::None => 'HTTP only (:80, no TLS)', - self::Both => 'HTTP (:80) → redirect to HTTPS (:443)', - }; - } -} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php deleted file mode 100644 index c62c355..0000000 --- a/plugins/Edge/Infrastructure/Cli/EdgeApplyCommand.php +++ /dev/null @@ -1,258 +0,0 @@ -name = 'edge:apply'; - $this->description = 'Generate the nginx/Apache edge config from platform domains, then reload the server'; - - $this->addOption('dry-run', '', 'Print the config that would be written; change nothing'); - $this->addOption('no-reload', '', 'Write the config file but do not validate or reload'); - $this->addOption('no-hosts', '', 'Skip writing local (.local/.test) domains to /etc/hosts'); - $this->addOption('all', '', 'Include every registered project (default: only the current one)'); - $this->addOption('local', '', 'APP_ENV=local — developer machine (DEVELOPMENT cache profile)'); - $this->addOption('dev', '', 'Alias for --local (note: `hkm` itself consumes --dev for kernel selection)'); - $this->addOption('development', 'd', 'APP_ENV=development — shared dev/staging server (DEVELOPMENT cache profile)'); - $this->addOption('production', '', 'APP_ENV=production — live server (PRODUCTION cache profile)'); - $this->addOption('tls', '', 'TLS mode: ssl (HTTPS only), none (HTTP only), both (HTTP→HTTPS redirect)', true); - $this->addOption('no-ssl', '', 'Plain HTTP only, listen on :80 — alias for --tls=none'); - $this->addOption('ssl-cert', '', 'Path to the TLS certificate (overrides config ssl.cert)', true); - $this->addOption('ssl-key', '', 'Path to the TLS private key (overrides config ssl.key)', true); - $this->addOption('nginx-only', '', 'Serve everything through nginx with NO Apache fallback (overrides auto-detection)'); - $this->addOption('apache-only', '', 'Serve everything through Apache with no fallback (overrides auto-detection)'); - } - - protected function handle(): int - { - $dryRun = $this->hasOption('dry-run'); - $reload = !$this->hasOption('no-reload'); - $hosts = $this->hasOption('no-hosts') ? false : null; // null = use config default - $all = $this->hasOption('all'); - - // TLS overrides — null means "use the config default". --no-ssl is a - // convenience alias for --tls=none. - $tlsMode = $this->hasOption('no-ssl') ? 'none' : $this->stringOption('tls'); - $sslCert = $this->stringOption('ssl-cert'); - $sslKey = $this->stringOption('ssl-key'); - - $appEnv = $this->resolveAppEnv(); - if ($appEnv === false) { - $this->error('Pick ONE environment: --local (or --dev), --development/-d, or --production.'); - - return self::INVALID; - } - - // A LOCAL developer machine serves plain HTTP by default — no self-signed - // cert to trust, no HSTS/cert warnings. Only applies when the user did NOT - // explicitly choose a TLS mode (--tls=... / --no-ssl always win, so - // `--local --tls=ssl` still gets HTTPS). Shared dev/staging + production - // keep the config default (ssl). - if ($tlsMode === null && $appEnv === 'local') { - $tlsMode = 'none'; - } - - $force = $this->resolveForce(); - if ($force === false) { - $this->error('Pick at most ONE of --nginx-only or --apache-only.'); - - return self::INVALID; - } - - $result = $this->edge->apply( - reload: $reload, - dryRun: $dryRun, - manageHosts: $hosts, - all: $all, - tlsMode: $tlsMode, - sslCert: $sslCert, - sslKey: $sslKey, - appEnv: $appEnv, - force: $force, - ); - - $this->reportHosts($result['hosts'] ?? null); - - if (($result['ok'] ?? false) !== true) { - $this->error('Edge apply failed [' . ($result['strategy'] ?? '?') . ']: ' . ($result['message'] ?? 'unknown error')); - foreach ((array) ($result['steps'] ?? []) as $step) { - $this->muted(' - ' . $step); - } - - return self::FAILURE; - } - - if ($dryRun) { - $this->info('strategy: ' . $result['strategy'] . ' → ' . $result['path'] . ' (' . ($result['sites'] ?? 0) . ' site(s))'); - $this->newLine(); - $this->muted($result['contents']); - $this->reportStream($result['stream'] ?? null, dryRun: true); - - return self::SUCCESS; - } - - $this->success('Edge applied [' . $result['strategy'] . ']'); - foreach ((array) ($result['steps'] ?? []) as $step) { - $this->info(' - ' . $step); - } - $this->reportStream($result['stream'] ?? null, dryRun: false); - - return self::SUCCESS; - } - - /** - * The APP_ENV selected by the environment flags: 'local' | 'development' | - * 'production', null when none was given (fall back to the configured - * APP_ENV), or false when more than one was passed. - * - * These flags are deliberately EDGE-LOCAL (not launcher-global) — they only - * steer this command's cache profile. - * - * NOTE: the `hkm` launcher consumes `--dev` for KERNEL selection and strips - * it before the command runs, so use `--local` to select APP_ENV=local via - * the launcher. `--dev` is kept as an alias for direct invocation. - */ - private function resolveAppEnv(): string|false|null - { - $picked = array_keys(array_filter([ - 'local' => $this->hasOption('local') || $this->hasOption('dev'), - 'development' => $this->hasOption('development'), - 'production' => $this->hasOption('production'), - ])); - - return match (\count($picked)) { - 0 => null, - 1 => $picked[0], - default => false, - }; - } - - /** - * The forced strategy from --nginx-only / --apache-only: 'nginx-only' | - * 'apache-only', null when neither was given (auto-detect), or false when - * both were passed (mutually exclusive). - */ - private function resolveForce(): string|false|null - { - $picked = array_keys(array_filter([ - 'nginx-only' => $this->hasOption('nginx-only'), - 'apache-only' => $this->hasOption('apache-only'), - ])); - - return match (\count($picked)) { - 0 => null, - 1 => $picked[0], - default => false, - }; - } - - /** A value-accepting option as a non-empty string, or null (unset / bare flag). */ - private function stringOption(string $name): ?string - { - $value = $this->option($name); - - return is_string($value) && $value !== '' ? $value : null; - } - - /** - * Report the in-place merge into the host's existing SNI stream splitter. - * - * @param array|null $stream - */ - private function reportStream(?array $stream, bool $dryRun): void - { - if ($stream === null) { - return; - } - $file = (string) ($stream['file'] ?? '?'); - $added = (array) ($stream['added'] ?? []); - $present = (array) ($stream['present'] ?? []); - - if (($stream['ok'] ?? false) !== true) { - $this->warning('stream: could NOT update ' . $file . ' — ' . ($stream['message'] ?? 'error')); - - return; - } - if ($present !== []) { - $this->muted('stream: ' . \count($present) . ' domain(s) already in the map (left untouched)'); - } - if ($added === []) { - $this->info('stream: ' . $file . ' already current — no domains to add'); - - return; - } - $verb = $dryRun ? 'would merge' : 'merged'; - $this->info("stream: {$verb} " . \count($added) . " domain(s) into {$file} → nginx_backend"); - foreach ($added as $d) { - $this->muted(' + ' . $d); - } - } - - /** @param array|null $hosts */ - private function reportHosts(?array $hosts): void - { - if ($hosts === null) { - return; - } - $count = (int) ($hosts['count'] ?? 0); - $path = (string) ($hosts['path'] ?? '/etc/hosts'); - - if (($hosts['ok'] ?? false) !== true) { - $this->warning("hosts: {$count} local domain(s) NOT written — " . ($hosts['message'] ?? 'error')); - return; - } - if (($hosts['dry_run'] ?? false) === true) { - $this->info("hosts: would sync {$count} local domain(s) to {$path}"); - return; - } - $verb = ($hosts['changed'] ?? false) ? 'synced' : 'already current'; - $this->info("hosts: {$verb} {$count} local domain(s) in {$path}"); - } -} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php deleted file mode 100644 index 4098d70..0000000 --- a/plugins/Edge/Infrastructure/Cli/EdgeHostsCommand.php +++ /dev/null @@ -1,71 +0,0 @@ -name = 'edge:hosts'; - $this->description = 'Sync local (.local/.test) platform domains into /etc/hosts'; - - $this->addOption('dry-run', '', 'Show what would change; write nothing'); - $this->addOption('remove', '', 'Remove the HKM-managed block from the hosts file'); - $this->addOption('all', '', 'Include every registered project (default: only the current one)'); - $this->addOption('force', '', 'Allow running outside dev mode (normally requires --dev)'); - } - - protected function handle(): int - { - $result = $this->edge->syncHosts( - remove: $this->hasOption('remove'), - dryRun: $this->hasOption('dry-run'), - all: $this->hasOption('all'), - force: $this->hasOption('force'), - ); - - $count = (int) ($result['count'] ?? 0); - $path = (string) ($result['path'] ?? '/etc/hosts'); - $skipped = (array) ($result['skipped'] ?? []); - - if (($result['ok'] ?? false) !== true) { - $this->error('hosts sync failed: ' . ($result['message'] ?? 'unknown error')); - return self::FAILURE; - } - - if ($skipped !== []) { - $this->muted('already in ' . $path . ' (left untouched): ' . implode(', ', $skipped)); - } - - if (($result['dry_run'] ?? false) === true) { - $this->info("Would write {$count} new local domain(s) to {$path}:"); - $this->newLine(); - $this->muted(($result['block'] ?? '') === '' ? '(nothing to add — managed block would be removed)' : (string) $result['block']); - return self::SUCCESS; - } - - $verb = ($result['changed'] ?? false) ? 'Synced' : 'Already current —'; - $this->success("{$verb} {$count} local domain(s) in {$path}."); - - return self::SUCCESS; - } -} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeServiceCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeServiceCommand.php deleted file mode 100644 index ea02877..0000000 --- a/plugins/Edge/Infrastructure/Cli/EdgeServiceCommand.php +++ /dev/null @@ -1,135 +0,0 @@ - - */ -final class EdgeServiceCommand extends AbstractCommand -{ - private const SYSTEMD_DIR = '/etc/systemd/system'; - private const SUPERVISOR_DIR = '/etc/supervisor/conf.d'; - - public function __construct(private readonly EdgeServiceContract $edge) - { - parent::__construct(); - } - - protected function configure(): void - { - $this->name = 'edge:service'; - $this->description = 'Generate the systemd/supervisor unit for a project\'s OpenSwoole server'; - - $this->addOption('supervisor', '', 'Render a supervisor program block instead of a systemd unit'); - $this->addOption('all', '', 'Include every registered project (default: only the current one)'); - $this->addOption('user', '', 'User/group the service runs as (default: www-data)', true); - $this->addOption('write', '', 'Write the unit(s) to disk; optionally give a target directory', true); - $this->addOption('local', '', 'APP_ENV=local'); - $this->addOption('dev', '', 'Alias for --local'); - $this->addOption('development', 'd', 'APP_ENV=development'); - $this->addOption('production', '', 'APP_ENV=production'); - } - - protected function handle(): int - { - $supervisor = $this->hasOption('supervisor'); - $format = $supervisor ? 'supervisor' : 'systemd'; - - $appEnv = $this->resolveAppEnv(); - if ($appEnv === false) { - $this->error('Pick ONE environment: --local (or --dev), --development/-d, or --production.'); - - return self::INVALID; - } - - $user = $this->stringOption('user') ?? 'www-data'; - $units = $this->edge->serviceUnits($format, $this->hasOption('all'), $appEnv, $user); - - if ($units === []) { - $this->warning('No OpenSwoole project in scope — nothing to generate.'); - $this->muted(' PHP-FPM projects need no unit (php-fpm supervises them). Set'); - $this->muted(' proj.json "edge": { "runtime": "openswoole" } to switch a project over.'); - - return self::SUCCESS; - } - - $write = $this->option('write'); - if ($write === null || $write === false) { - foreach ($units as $name => $body) { - $this->info('# ' . $name . ($supervisor ? '.conf' : '.service')); - $this->newLine(); - $this->muted($body); - } - - return self::SUCCESS; - } - - $dir = is_string($write) && $write !== '' - ? rtrim($write, '/') - : ($supervisor ? self::SUPERVISOR_DIR : self::SYSTEMD_DIR); - - if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) { - $this->error("Cannot create directory {$dir}"); - - return self::FAILURE; - } - - foreach ($units as $name => $body) { - $file = $dir . '/' . $name . ($supervisor ? '.conf' : '.service'); - if (@file_put_contents($file, $body) === false) { - $this->error("Failed to write {$file} (need root?)"); - - return self::FAILURE; - } - $this->success('wrote ' . $file); - } - - $this->newLine(); - $this->info($supervisor - ? 'Next: supervisorctl reread && supervisorctl update' - : 'Next: systemctl daemon-reload && systemctl enable --now '); - - return self::SUCCESS; - } - - /** Mirrors edge:apply — 'local'|'development'|'production', null, or false on conflict. */ - private function resolveAppEnv(): string|false|null - { - $picked = array_keys(array_filter([ - 'local' => $this->hasOption('local') || $this->hasOption('dev'), - 'development' => $this->hasOption('development'), - 'production' => $this->hasOption('production'), - ])); - - return match (\count($picked)) { - 0 => null, - 1 => $picked[0], - default => false, - }; - } - - private function stringOption(string $name): ?string - { - $value = $this->option($name); - - return is_string($value) && $value !== '' ? $value : null; - } -} diff --git a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php b/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php deleted file mode 100644 index 7464bde..0000000 --- a/plugins/Edge/Infrastructure/Cli/EdgeStatusCommand.php +++ /dev/null @@ -1,82 +0,0 @@ -name = 'edge:status'; - $this->description = 'Detect nginx/Apache and show the edge routing strategy that would be applied'; - - $this->addOption('all', '', 'Include every registered project (default: only the current one)'); - $this->addOption('nginx-only', '', 'Preview the nginx-only strategy (NO Apache fallback)'); - $this->addOption('apache-only', '', 'Preview the apache-only strategy (no fallback)'); - } - - protected function handle(): int - { - $force = match (true) { - $this->hasOption('nginx-only') && $this->hasOption('apache-only') => 'both', - $this->hasOption('nginx-only') => 'nginx-only', - $this->hasOption('apache-only') => 'apache-only', - default => null, - }; - if ($force === 'both') { - $this->error('Pick at most ONE of --nginx-only or --apache-only.'); - - return self::INVALID; - } - - $plan = $this->edge->plan($this->hasOption('all'), force: $force); - $stack = $plan->stack; - - $this->section('Edge — detected stack'); - $yn = static fn (bool $b): string => $b ? 'yes' : 'no'; - $this->info('nginx installed : ' . $yn($stack->nginxInstalled)); - $this->info('nginx active : ' . $yn($stack->nginxActive)); - $this->info('nginx stream : ' . $yn($stack->nginxHasStream)); - $this->info('nginx stream cfg: ' . $yn($stack->nginxHasStreamConfig) . ($stack->nginxHasStreamConfig ? ' (existing splitter — will be reused)' : '')); - $this->info('apache installed: ' . $yn($stack->apacheInstalled)); - $this->info('apache active : ' . $yn($stack->apacheActive)); - - $php = $this->edge->phpFpm(); - $this->info('php (cli) : ' . $php['version']); - $this->info('php-fpm socket : ' . $php['socket']); - if ($php['active'] !== []) { - $this->info('php-fpm active : ' . implode(', ', $php['active'])); - } - $this->newLine(); - $this->success('strategy: ' . $plan->strategy->label() . ($plan->reuseStream ? ' — reusing the existing nginx stream splitter' : '')); - $this->info('project sites : ' . count($plan->sites)); - foreach ($plan->sites as $site) { - $this->info(sprintf( - ' • %s [%s → %s] %s', - $site->name, - $site->model->value, - $site->upstream, - $site->publicDomains === [] ? '(no public domains)' : implode(', ', $site->publicDomains), - )); - } - $this->info('local domains : ' . count($plan->localDomains) . ($plan->localDomains === [] ? '' : ' → /etc/hosts (' . implode(', ', $plan->localDomains) . ')')); - $this->info('target : ' . ($plan->targetPath === '' ? '(none)' : $plan->targetPath)); - - return self::SUCCESS; - } -} diff --git a/plugins/Edge/Infrastructure/ConfigRenderer.php b/plugins/Edge/Infrastructure/ConfigRenderer.php deleted file mode 100644 index d9ab373..0000000 --- a/plugins/Edge/Infrastructure/ConfigRenderer.php +++ /dev/null @@ -1,1277 +0,0 @@ -/app/public, FPM fastcgi or Swoole proxy, with the run-env injected) - * modeled on templates/app/{nginx,apache}.conf.example, plus — for the stream - * strategy — the nginx SNI splitter that routes SNI → nginx (:444) / Apache. - */ -final class ConfigRenderer -{ - /** - * @param list $sites - * @return array{0: string, 1: string} [targetPath, contents] ('' path for None) - */ - public function render(Strategy $strategy, array $sites, TlsConfig $tls, ServerStack $stack, CacheProfile $profile, bool $reuseStream = false): array - { - $sslPort = (int) edge_config('listen', 443); - $httpPort = (int) edge_config('http', 80); - - return match ($strategy) { - Strategy::NginxStream => [ - (string) edge_config('paths.stream'), - // The stream splitter is a TLS SNI router by nature, so the - // internal nginx vhosts behind it always terminate TLS on the - // internal port — the chosen mode applies to the single-server - // strategies (nginx-only / apache-only), not the L4 splitter. - // - // When an nginx stream splitter is ALREADY configured on the host, - // we reuse it and emit ONLY the internal backend vhosts, so we - // never write a second, conflicting `stream {}` block. - ($reuseStream ? $this->reuseStreamBanner() : $this->stream($sites) . "\n") - . $this->nginxVhosts($sites, $tls->withMode(TlsMode::Ssl), $httpPort, $this->nginxInternalPort(), $stack, $profile), - ], - Strategy::NginxOnly => [ - (string) edge_config('paths.nginx'), - // The vhost LISTENS on the configured TLS port — 443 standalone, - // but 444 (or whatever EDGE_NGINX_SSL_PORT is) when this host also - // runs an SNI `stream {}` router that already owns :443. Emitting - // `listen 443 ssl` there would collide and stop nginx entirely. The - // :80→HTTPS redirect still targets the PUBLIC port (below). - $this->nginxVhosts($sites, $tls, $httpPort, $this->nginxSslListenPort($stack), $stack, $profile), - ], - Strategy::ApacheOnly => [ - (string) edge_config('paths.apache'), - $this->apacheVhosts($sites, $tls, $httpPort, $sslPort, $stack), - ], - Strategy::None => ['', ''], - }; - } - - /** - * Header emitted instead of a fresh `stream {}` block when the host already - * has an nginx SNI splitter — records WHY no stream block is present here. - */ - private function reuseStreamBanner(): string - { - $port = $this->nginxInternalPort(); - - return "# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand.\n" - . "# An existing nginx `stream {}` SNI splitter was detected on this host, so\n" - . "# Edge is REUSING it — no second stream block is written here. Only the\n" - . "# internal backend vhosts (TLS-terminating on :{$port}) are emitted below.\n" - . "# Point your existing splitter's nginx_backend upstream at 127.0.0.1:{$port}.\n\n"; - } - - // ── nginx SNI stream splitter (L4) ──────────────────────────────────────── - - /** @param list $sites */ - private function stream(array $sites): string - { - $map = ''; - foreach ($this->publicDomains($sites) as $d) { - $pad = str_repeat(' ', max(1, 42 - strlen($d))); - $map .= " {$d}{$pad}nginx_backend;\n"; - } - - $tpl = <<<'NGINX' -# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand. -# SNI TLS router: server name is read WITHOUT decrypting (ssl_preread), then the -# raw TLS stream is forwarded. Platform domains → nginx (%NGINX%); everything -# else → Apache (%APACHE%). This block lives at the nginx MAIN context. -stream { - upstream nginx_backend { server %NGINX%; } - upstream apache_ssl { server %APACHE%; } - - map $ssl_preread_server_name $backend_name { -%MAP% default apache_ssl; - } - - server { - listen %LISTEN%; - proxy_pass $backend_name; - ssl_preread on; - } -} -NGINX; - - return $this->fill($tpl, [ - '%NGINX%' => (string) edge_config('upstreams.nginx'), - '%APACHE%' => (string) edge_config('upstreams.apache'), - '%LISTEN%' => (string) (int) edge_config('listen', 443), - '%MAP%' => $map, - ]); - } - - // ── per-project nginx vhosts ────────────────────────────────────────────── - - /** @param list $sites */ - private function nginxVhosts(array $sites, TlsConfig $tls, int $httpPort, int $sslPort, ServerStack $stack, CacheProfile $profile): string - { - $out = "# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand.\n"; - - // One map for the whole file — nginx rejects a duplicate map for the - // same variable, so it cannot live in the per-site template. - $hasSwoole = false; - foreach ($sites as $site) { - if ($site->servesPublic() && $site->model === ServeModel::Swoole) { - $hasSwoole = true; - break; - } - } - $prelude = $this->nginxHttpPrelude(); - if ($prelude !== '') { - $out .= "\n" . $prelude; - } - // File-level CORS allowlist map (once — nginx rejects a duplicate map for - // the same variable), emitted only when allowlist CORS is configured. - $cors = $this->corsConfig(); - if ($cors['mode'] === 'allowlist') { - $out .= "\n" . $this->nginxCorsMap($cors); - } - if ($hasSwoole) { - $out .= "\n" . $this->connectionUpgradeMap(); - } - - foreach ($sites as $site) { - if (!$site->servesPublic()) { - continue; - } - $out .= "\n" . ($site->model === ServeModel::Swoole - ? $this->nginxSwoole($site, $tls, $httpPort, $sslPort, $stack, $profile) - : $this->nginxFpm($site, $tls, $httpPort, $sslPort, $stack, $profile)); - } - - return rtrim($out, "\n") . "\n"; - } - - /** - * Everything an nginx vhost shares regardless of runtime: dev extras, - * compression, the FULL header set (CORS + security + HSTS), the cache-profile - * banner, the static asset location, deny lists and the method guard. Kept in - * ONE place so the PHP-FPM and OpenSwoole vhosts can never drift apart, and so - * every location that emits an `add_header` emits the SAME complete set. - * - * @return array{dev: bool, logs: string, compression: string, - * serverHeaders: string, banner: string, static: string, - * devLoc: string, dynamicHeaders: string, proxyHeaders: string, - * rateLimit: string, deny: string, methodGuard: string, - * corsMap: string} - */ - private function vhostCommon(Site $site, TlsConfig $tls, ServerStack $stack, CacheProfile $profile): array - { - // Dev-only extras (verbose logging, Disallow-all robots, stub_status). - // These follow the APP_ENV-derived cache profile so NOTHING in the vhost - // is inferred from the kernel mode (HKM_DEV); set EDGE_DEV_VHOST to force - // them on/off independently. - $devOverride = edge_config('dev_vhost', null); - $dev = $devOverride === null ? $profile->isDevelopment() : (bool) $devOverride; - - // error_log debug is OPT-IN (EDGE_NGINX_DEBUG_LOG): it only works on an - // nginx built --with-debug, is extremely verbose (can be gigabytes), and - // may log request internals containing session ids / tokens. Default warn. - $debugLog = (bool) edge_config('debug_log', false); - $errLevel = $debugLog ? 'debug' : 'warn'; - - // Per-site logs are emitted in BOTH profiles — they matter MORE in - // production, where an incident is reconstructed across many domains - // sharing one host; without them nginx falls back to the single global - // log. When the prelude owns logging every vhost uses its buffered format; - // otherwise a plain combined access log. Set EDGE_PER_SITE_LOGS=0 to fall - // back to the global log. - $logs = ''; - if ((bool) edge_config('per_site_logs', true)) { - $preludeOn = (bool) edge_config('http_prelude.enabled', false); - $format = (string) edge_config('http_prelude.log_format', 'cf_realip'); - if ($preludeOn && $format !== '') { - $buffer = (string) edge_config('http_prelude.log_buffer', '32k'); - $flush = (string) edge_config('http_prelude.log_flush', '5s'); - $logs = " access_log /var/log/nginx/{$site->name}.access.log {$format} buffer={$buffer} flush={$flush};\n" - . " error_log /var/log/nginx/{$site->name}.error.log {$errLevel};\n"; - } else { - $logs = " access_log /var/log/nginx/{$site->name}.access.log combined;\n" - . " error_log /var/log/nginx/{$site->name}.error.log {$errLevel};\n"; - } - } - - $cors = $this->corsConfig(); - $compression = $this->nginxCompression($stack); - - // THE single header set — CORS (if configured) + security + HSTS. Built at - // two indents: 4 for server scope, 8 for inside a location. Any location - // that declares an add_header of its own MUST re-emit this whole set, - // because one add_header in a location drops ALL inherited ones. - $serverHeaders = $this->headerSet(4, $tls, $profile, $cors); - $locHeaders = $this->headerSet(8, $tls, $profile, $cors); - - $cacheDev = $profile->isDevelopment(); - $cacheHtml = (bool) edge_config('cache.browser_html', false); - $cacheAssets = (bool) edge_config('cache.browser_assets', true); - $assetTtl = (int) edge_config('cache.browser_assets_ttl', 31536000); - $cloudflare = (bool) edge_config('cache.cloudflare', true); - - // Dynamic responses (front controller / proxied app) are NEVER cached - // unless HTML caching is explicitly opted into. - if ($cacheHtml) { - $cache = ''; - } elseif ($cacheDev) { - $cache = " add_header Cache-Control \"no-store, no-cache, must-revalidate\" always;\n" - . " add_header Pragma \"no-cache\" always;\n" - . " add_header Expires \"0\" always;\n"; - } else { - $cache = " add_header Cache-Control \"no-store, no-cache, must-revalidate\" always;\n"; - } - // index.php ALWAYS emits the full header set (so CORS lands on real app / - // API responses, which all route through the front controller), plus the - // cache directives. - $dynamicHeaders = "\n" . $locHeaders . $cache; - - // Proxied (OpenSwoole) responses: development forces no-store; PRODUCTION - // lets the app own Cache-Control. The header set is still emitted so CORS - // and security headers reach proxied responses too. - $proxyCache = (!$cacheHtml && $cacheDev) ? $cache : ''; - $proxyHeaders = "\n" . $locHeaders . $proxyCache; - - // Static assets. Disabled (no-store) in dev or when asset caching is off; - // otherwise immutable, long-lived caching of FINGERPRINTED assets only. - // PRODUCTION drops `map` (source maps expose original source) and `json` - // (stray build/config) from the served set; DEVELOPMENT keeps them. - if ($cacheDev || !$cacheAssets) { - $assetExt = 'css|js|map|json|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|otf|eot|pdf|txt|xml'; - $assetCache = " expires off;\n" - . " add_header Cache-Control \"no-store, no-cache, must-revalidate\" always;\n" - . " add_header Pragma \"no-cache\" always;\n" - . " add_header Expires \"0\" always;\n" - . $locHeaders - . " access_log off;\n"; - } else { - $assetExt = 'css|js|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|otf|eot'; - $control = $cloudflare ? 'public, immutable' : 'public'; - $assetCache = " expires " . $this->expiresValue($assetTtl) . ";\n" - . " add_header Cache-Control \"{$control}\" always;\n" - . $locHeaders - . " access_log off;\n"; - } - // Static assets resolve ONLY under the public root; a miss is a hard 404 - // and is never forwarded to the application (same rule for both runtimes). - $static = " location ~* \\.({$assetExt})\$ {\n" - . $assetCache - . " try_files \$uri =404;\n }"; - - // robots.txt (dev only — Disallow all). /nginx-status is gated on the TRUE - // development profile, never a mere EDGE_DEV_VHOST override: behind the - // production SNI stream splitter every peer looks like 127.0.0.1, so an - // `allow 127.0.0.1` there would expose stub_status to the whole internet. - $devLoc = ''; - if ($dev) { - $devLoc .= "\n location = /robots.txt {\n access_log off;\n" - . " return 200 \"User-agent: *\\nDisallow: /\\n\";\n }\n"; - } - if ($profile->isDevelopment() && (bool) edge_config('nginx_status', true)) { - $devLoc .= "\n location = /nginx-status {\n stub_status on;\n" - . " allow 127.0.0.1;\n allow ::1;\n deny all;\n }\n"; - } - - return [ - 'dev' => $dev, - 'logs' => $logs, - 'rateLimit' => $this->nginxRateLimit($profile), - 'compression' => $compression, - 'serverHeaders' => $serverHeaders, - 'banner' => $profile->banner(), - 'static' => $static, - 'devLoc' => $devLoc, - 'dynamicHeaders' => $dynamicHeaders, - 'proxyHeaders' => $proxyHeaders, - 'deny' => $this->nginxDenyLocations($profile), - 'methodGuard' => $this->nginxMethodGuard(), - 'corsMap' => $cors['mode'] === 'allowlist' ? $this->nginxCorsMap($cors) : '', - ]; - } - - /** - * Resolve the CORS policy. The wildcard is OPT-IN — a dev host reachable - * beyond localhost with `Allow-Origin: *` + `Allow-Headers: Authorization` - * lets any page a developer visits make authenticated cross-origin reads. - * - * EDGE_CORS = off (default) | allowlist | wildcard - * EDGE_CORS_ORIGINS = https://a.com,https://b.com (allowlist mode) - * - * @return array{mode: string, origins: list, methods: string, headers: string, credentials: bool} - */ - private function corsConfig(): array - { - $mode = strtolower(trim((string) edge_config('cors.mode', 'off'))); - if (!in_array($mode, ['off', 'allowlist', 'wildcard'], true)) { - $mode = 'off'; - } - $origins = array_values(array_filter((array) edge_config('cors.origins', []))); - if ($mode === 'allowlist' && $origins === []) { - $mode = 'off'; // allowlist with nothing to allow = no CORS - } - - return [ - 'mode' => $mode, - 'origins' => $origins, - 'methods' => (string) edge_config('cors.methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'), - 'headers' => (string) edge_config('cors.headers', 'Content-Type, Authorization, X-Requested-With'), - 'credentials' => (bool) edge_config('cors.credentials', false), - ]; - } - - /** - * The complete header set at the given indent: CORS (per policy) + the three - * security headers + HSTS (TLS modes only, profile-aware). This is emitted at - * server scope AND repeated in every location that declares any add_header. - * - * @param array{mode: string, origins: list, methods: string, headers: string, credentials: bool} $cors - */ - private function headerSet(int $indent, TlsConfig $tls, CacheProfile $profile, array $cors): string - { - $pad = str_repeat(' ', $indent); - $out = ''; - - // ── CORS ────────────────────────────────────────────────────────────── - if ($cors['mode'] === 'wildcard') { - $out .= "{$pad}add_header Access-Control-Allow-Origin \"*\" always;\n"; - } elseif ($cors['mode'] === 'allowlist') { - // $cors_allow_origin is set by the file-level map — empty (and so the - // header is omitted by nginx) for any origin not on the allowlist. - $out .= "{$pad}add_header Access-Control-Allow-Origin \$cors_allow_origin always;\n" - . "{$pad}add_header Vary Origin always;\n"; - } - if ($cors['mode'] !== 'off') { - $out .= "{$pad}add_header Access-Control-Allow-Methods \"{$cors['methods']}\" always;\n" - . "{$pad}add_header Access-Control-Allow-Headers \"{$cors['headers']}\" always;\n"; - if ($cors['credentials']) { - $out .= "{$pad}add_header Access-Control-Allow-Credentials \"true\" always;\n"; - } - } - - // ── Security ────────────────────────────────────────────────────────── - $out .= "{$pad}add_header X-Content-Type-Options \"nosniff\" always;\n" - . "{$pad}add_header X-Frame-Options \"SAMEORIGIN\" always;\n" - . "{$pad}add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n"; - - // ── HSTS (TLS only) ─────────────────────────────────────────────────── - if ($tls->mode->usesTls()) { - $out .= $this->nginxHsts($indent, $profile); - } - - return $out; - } - - /** - * File-level `map $http_origin $cors_allow_origin { … }` for allowlist CORS — - * echoes the request Origin back only when it is on the list, else empty. - * Emitted once per file (nginx rejects a duplicate map for the same variable). - * - * @param array{origins: list} $cors - */ - private function nginxCorsMap(array $cors): string - { - $body = " default \"\";\n"; - foreach ($cors['origins'] as $origin) { - $origin = trim((string) $origin); - if ($origin === '') { - continue; - } - $q = '"' . $this->escapeNginx($origin) . '"'; - $body .= " {$q} {$q};\n"; - } - - return "# CORS allowlist — echo the Origin only when explicitly permitted.\n" - . "map \$http_origin \$cors_allow_origin {\n{$body}}\n"; - } - - /** - * Deny access to sensitive files and directories that would otherwise fall - * through `location /` to `try_files $uri` and be served as static content. - * - * ORDER MATTERS: nginx evaluates regex locations in file order, first match - * wins — so these MUST be emitted BEFORE the static-asset regex, or a denied - * directory file with a whitelisted extension (e.g. `vendor/composer/ - * installed.json`) would be served by the static rule instead. Directories use - * `^~` prefix locations, which beat regex matching regardless of position and - * cannot be shadowed. Emitted in BOTH profiles (defense in depth). - * - * NOTE: dropping an extension from the static-asset location is NOT enough to - * block it — `location /` still falls through to `try_files $uri`, serving any - * file that exists on disk. Only an explicit deny blocks it. So in PRODUCTION - * `.map` (original source maps) is DENIED here, not merely un-cached. - */ - private function nginxDenyLocations(CacheProfile $profile): string - { - $out = "\n"; - $dirs = $this->denyDirs(); - if ($dirs !== []) { - $out .= " # Sensitive directories — prefix-matched (^~) so they win over the\n" - . " # static-asset regex below regardless of file extension.\n"; - foreach ($dirs as $dir) { - $path = '/' . trim($dir, '/') . '/'; - $out .= " location ^~ {$path} { deny all; access_log off; log_not_found off; }\n"; - } - $out .= "\n"; - } - - // Source maps expose original unminified source — DENIED in production - // (kept in development for debugging). This must be a deny, not just - // removal from the static rule, or `location /`/try_files serves it. - $ext = 'env|log|sql|sqlite|bak|backup|swp|dist|sh|ini|conf|yml|yaml|lock'; - if (!$profile->isDevelopment()) { - $ext .= '|map'; - } - - // Hidden files (dotfiles) — but .well-known stays reachable (ACME). This - // regex sits before the static rule so a dotfile with a whitelisted - // extension (e.g. /.vscode/settings.json) is denied, not served. - $out .= " # Hidden files denied, but .well-known stays reachable (ACME/Let's Encrypt).\n" - . " location ~ /\\.(?!well-known) { deny all; }\n\n" - . " # Sensitive file extensions — never served as static content.\n" - . " location ~* \\.({$ext})\$ {\n" - . " deny all; access_log off; log_not_found off;\n }"; - - return $out; - } - - /** - * Directories to deny (prefix-matched). Configurable per project because the - * right set is app-specific — notably `storage` is NOT denied by default: a - * Laravel-style `public/storage` symlink serves intended user uploads, and a - * blanket deny would break it. Add it via EDGE_DENY_DIRS where appropriate. - * Entries are validated to a safe path charset so config can never inject a - * directive. - * - * @return list - */ - private function denyDirs(): array - { - $list = edge_config('deny_dirs', null); - if ($list === null) { - $list = ['vendor', 'node_modules', 'tests', '.git', '.github', 'bootstrap/cache']; - } - - $out = []; - foreach ((array) $list as $dir) { - $dir = trim((string) $dir, '/ '); - if ($dir !== '' && preg_match('#^[A-Za-z0-9._/-]+$#', $dir)) { - $out[] = $dir; - } - } - - return array_values(array_unique($out)); - } - - /** - * HTTP method guard — reject verbs the app never uses. Configurable because - * these apps legitimately use PUT/PATCH/DELETE (REST); the default therefore - * allows the full REST set rather than the GET/HEAD/POST minimum, so the guard - * blocks oddities (TRACE, CONNECT, …) without breaking the API. Set - * EDGE_ALLOWED_METHODS to tighten. Empty disables the guard. - */ - private function nginxMethodGuard(): string - { - $methods = strtoupper(trim((string) edge_config('allowed_methods', 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS'))); - $methods = str_replace([',', ' '], ['|', ''], $methods); - if ($methods === '') { - return ''; - } - - return " # Reject any HTTP method the application does not use.\n" - . " if (\$request_method !~ ^({$methods})\$) { return 405; }\n"; - } - - private function nginxFpm(Site $site, TlsConfig $tls, int $httpPort, int $sslPort, ServerStack $stack, CacheProfile $profile): string - { - $paths = $this->pathBanner($site); - $params = ''; - foreach ($site->env as $k => $v) { - $params .= sprintf(" fastcgi_param %s \"%s\";\n", $k, $this->escapeNginx($v)); - } - - [$listen, $ssl] = $this->nginxTls($tls, $httpPort, $sslPort); - $redirect = $tls->mode === TlsMode::Both - ? $this->nginxRedirect($site->publicDomains, $site->publicRoot(), $httpPort, (int) edge_config('listen', 443)) . "\n\n" - : ''; - - $c = $this->vhostCommon($site, $tls, $stack, $profile); - - $tpl = <<<'NGINX' -%CACHEPROFILE%%RUNTIME%%PATHS%# Project: %NAME% (PHP-FPM) -%REDIRECT%server { - %LISTEN% - server_name %NAMES%; - - root %DOCROOT%; - index index.php; -%SSL% -%LOGS%%HEADERS% server_tokens off; - client_max_body_size 25m; -%METHODS%%RATELIMIT%%COMPRESSION% - # Front controller — only /index.php executes PHP. - location = /index.php { - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME $document_root/index.php; -%PARAMS% fastcgi_pass %UPSTREAM%; - - fastcgi_buffers 16 16k; - fastcgi_buffer_size 32k; - fastcgi_read_timeout 300s; - fastcgi_send_timeout 300s; - fastcgi_connect_timeout 60s; - # FastCGI microcache is OFF here; the session-keyed guards below mean it - # stays safe (never caches an authenticated response) if a cache zone is - # ever switched on upstream. - fastcgi_cache off; - fastcgi_no_cache $cookie_PHPSESSID; - fastcgi_cache_bypass $cookie_PHPSESSID; -%FCHEADERS% } - - # Any other .php file is not executed. - location ~ \.php$ { return 404; } -%DENY% - - # Static assets (evaluated AFTER the deny rules above so nothing inside a - # denied path can be served via a whitelisted extension). -%STATIC% - - location = /favicon.ico { - access_log off; - log_not_found off; - try_files $uri =404; - } -%DEVLOC% - error_page 404 /404.html; - error_page 500 502 503 504 /50x.html; - location ~ ^/(404|50x)\.html$ { - root %DOCROOT%; - internal; - } - - location / { try_files $uri /index.php$is_args$args; } -} -NGINX; - - return $this->fill($tpl, [ - '%NAME%' => $site->name, - '%CACHEPROFILE%' => $c['banner'], - '%RUNTIME%' => $site->model->banner(), - '%PATHS%' => $paths, - '%REDIRECT%' => $redirect, - '%LISTEN%' => $listen, - '%SSL%' => $ssl, - '%NAMES%' => implode(' ', $site->publicDomains), - '%DOCROOT%' => $site->publicRoot(), - '%PARAMS%' => $params, - '%LOGS%' => $c['logs'], - '%HEADERS%' => $c['serverHeaders'], - '%METHODS%' => $c['methodGuard'], - '%COMPRESSION%' => $c['compression'], - '%RATELIMIT%' => $c['rateLimit'], - '%FCHEADERS%' => $c['dynamicHeaders'], - '%STATIC%' => $c['static'], - '%DENY%' => $c['deny'], - '%DEVLOC%' => $c['devLoc'], - '%UPSTREAM%' => $site->upstream, - ]); - } - - /** Cloudflare's published edge ranges (www.cloudflare.com/ips). */ - private const CLOUDFLARE_RANGES = [ - '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', - '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', - '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', - '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22', - '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32', - '2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32', - ]; - - /** - * HTTP-context prerequisites the vhosts below depend on: the log_format, the - * rate-limit zones and the Cloudflare real-IP ranges. Emitted ONCE per file - * and only when explicitly enabled — re-declaring a zone or log_format that - * already exists in nginx.conf is a duplicate-definition error. - */ - private function nginxHttpPrelude(): string - { - if (!(bool) edge_config('http_prelude.enabled', false)) { - return ''; - } - - $out = "# ── HKM Edge: http-context prerequisites ────────────────────────────────\n"; - - $format = (string) edge_config('http_prelude.log_format', 'cf_realip'); - if ($format !== '') { - $out .= "# Access log format: real visitor IP first, then the CF edge that relayed it.\n" - . "log_format {$format} '\$remote_addr - \$remote_user [\$time_local] \"\$request\" '\n" - . " '\$status \$body_bytes_sent \"\$http_referer\" '\n" - . " '\"\$http_user_agent\" cf=\"\$http_cf_connecting_ip\" '\n" - . " 'rt=\$request_time urt=\"\$upstream_response_time\"';\n\n"; - } - - if ((bool) edge_config('http_prelude.rate_limit.enabled', true)) { - $reqZone = (string) edge_config('http_prelude.rate_limit.req_zone', 'general'); - $reqSize = (string) edge_config('http_prelude.rate_limit.req_size', '10m'); - $reqRate = (string) edge_config('http_prelude.rate_limit.req_rate', '10r/s'); - $connZone = (string) edge_config('http_prelude.rate_limit.conn_zone', 'perip'); - $connSize = (string) edge_config('http_prelude.rate_limit.conn_size', '10m'); - - // Keyed on $binary_remote_addr, which is the REAL client once the - // Cloudflare real-IP block below has rewritten it. - $out .= "# Rate-limit zones (keyed on the real client IP).\n" - . "limit_req_zone \$binary_remote_addr zone={$reqZone}:{$reqSize} rate={$reqRate};\n" - . "limit_conn_zone \$binary_remote_addr zone={$connZone}:{$connSize};\n\n"; - } - - if ((bool) edge_config('http_prelude.cloudflare.enabled', true)) { - $ranges = (array) edge_config('http_prelude.cloudflare.ranges', []); - $ranges = $ranges === [] ? self::CLOUDFLARE_RANGES : $ranges; - $header = (string) edge_config('http_prelude.cloudflare.header', 'CF-Connecting-IP'); - - $out .= "# Cloudflare: trust the edge and take the visitor IP from {$header},\n" - . "# so logs, rate limits and deny rules see the real client.\n"; - // The HTTP server sits behind the :443 SNI stream splitter, so the TCP - // peer at this layer is ALWAYS 127.0.0.1 — the real_ip module must trust - // the loopback hop or it never fires and $remote_addr stays 127.0.0.1 - // (collapsing every client into ONE rate-limit bucket, and logging - // 127.0.0.1 for everyone). SAFETY: only sound when :443 is firewalled to - // Cloudflare — otherwise a direct-to-origin client can spoof {$header}. - // For a stricter setup, enable PROXY protocol on the stream hop instead. - if ((bool) edge_config('http_prelude.cloudflare.trust_loopback', true)) { - $out .= "set_real_ip_from 127.0.0.1;\n" - . "set_real_ip_from ::1;\n"; - } - foreach ($ranges as $range) { - $out .= 'set_real_ip_from ' . trim((string) $range) . ";\n"; - } - $out .= "real_ip_header {$header};\n" - . "real_ip_recursive on;\n"; - } - - return rtrim($out, "\n") . "\n"; - } - - /** - * Per-vhost `limit_req` / `limit_conn`, matching the zones in the prelude. The - * DEVELOPMENT profile uses a looser burst so rapid page reloads don't trip 429s. - */ - private function nginxRateLimit(CacheProfile $profile): string - { - if (!(bool) edge_config('http_prelude.enabled', false) - || !(bool) edge_config('http_prelude.rate_limit.enabled', true)) { - return ''; - } - - $reqZone = (string) edge_config('http_prelude.rate_limit.req_zone', 'general'); - $default = $profile->isDevelopment() ? 200 : 50; - $burst = (int) edge_config('http_prelude.rate_limit.req_burst', $default); - $nodelay = (bool) edge_config('http_prelude.rate_limit.req_nodelay', true) ? ' nodelay' : ''; - $connZone = (string) edge_config('http_prelude.rate_limit.conn_zone', 'perip'); - $connLim = (int) edge_config('http_prelude.rate_limit.conn_limit', 100); - - return " limit_req zone={$reqZone} burst={$burst}{$nodelay};\n" - . " limit_conn {$connZone} {$connLim};\n"; - } - - /** - * The path provenance banner. Every generated path derives from PROJECT_ROOT, - * so the same template works for any checkout location — this records which - * roots produced the file. - */ - private function pathBanner(Site $site): string - { - $root = $site->root !== '' ? $site->root : dirname($site->docroot, 2); - $out = "# HKM Edge project root: {$root}\n" - . "# HKM Edge public root: {$site->publicRoot()}\n"; - if ($site->swoole !== null) { - $out .= "# HKM Edge swoole root: {$site->swooleRoot()}\n"; - } - - return $out; - } - - /** nginx upstream name for a project, e.g. `blog_backend`. */ - private function upstreamName(Site $site): string - { - $slug = strtolower((string) preg_replace('/[^a-z0-9]+/i', '_', $site->name)); - - return trim($slug, '_') . '_backend'; - } - - /** - * The `upstream _backend { … }` block for an OpenSwoole project: the - * balancing method, every backend with its failure thresholds, and the - * keepalive connection pool. Lives at http context, so it is emitted right - * before the site's server block. - */ - private function nginxUpstream(Site $site): string - { - $sw = $site->swoole; - $name = $this->upstreamName($site); - - $body = ''; - if ($sw->balance !== '') { - $body .= " # Load balancing method\n {$sw->balance};\n\n"; - } - $body .= " # OpenSwoole worker(s)\n"; - foreach ($sw->servers() as $server) { - $body .= " server {$server} max_fails={$sw->maxFails} fail_timeout={$sw->failTimeout};\n"; - } - if (count($sw->servers()) === 1) { - $body .= " # Add more workers via proj.json:\n" - . " # \"edge\": { \"openswoole\": { \"ports\": [9501, 9502, 9503] } }\n"; - } - if ($sw->keepalive > 0) { - $body .= "\n # Idle upstream connection pool\n keepalive {$sw->keepalive};\n"; - if ($sw->keepaliveTimeout !== '') { - $body .= " keepalive_timeout {$sw->keepaliveTimeout};\n"; - } - if ($sw->keepaliveRequests > 0) { - $body .= " keepalive_requests {$sw->keepaliveRequests};\n"; - } - } - - return "# OpenSwoole upstream for {$site->name}\nupstream {$name} {\n{$body}}\n"; - } - - /** - * The `$connection_upgrade` map, emitted ONCE per file when any OpenSwoole - * site is present (nginx rejects a duplicate map for the same variable). - * - * Why a map: WebSocket requests need `Connection: upgrade`, but sending that - * on EVERY request would defeat the upstream keepalive pool. The map sends - * `upgrade` only when the client asked to upgrade, and an empty value - * otherwise — which is exactly what keepalive to the backend requires. - */ - private function connectionUpgradeMap(): string - { - return <<<'NGINX' -# WebSocket upgrade switch. Only the /ws location sets Connection from this — -# normal traffic leaves the header alone. -map $http_upgrade $connection_upgrade { - default upgrade; - '' close; -} -NGINX . "\n"; - } - - private function nginxSwoole(Site $site, TlsConfig $tls, int $httpPort, int $sslPort, ServerStack $stack, CacheProfile $profile): string - { - [$listen, $ssl] = $this->nginxTls($tls, $httpPort, $sslPort); - $redirect = $tls->mode === TlsMode::Both - ? $this->nginxRedirect($site->publicDomains, $site->publicRoot(), $httpPort, (int) edge_config('listen', 443)) . "\n\n" - : ''; - - $c = $this->vhostCommon($site, $tls, $stack, $profile); - $sw = $site->swoole; - $upstream = $this->upstreamName($site); - $paths = $this->pathBanner($site); - - // Optional health endpoint — cheap, unlogged, never cached. - $health = ($sw?->healthPath ?? null) === null ? '' : $this->fill(<<<'NGINX' - - # Health check. - location %PATH% { - proxy_pass http://%UPSTREAM%; - proxy_set_header Host $host; - access_log off; - } -NGINX, ['%PATH%' => $sw->healthPath, '%UPSTREAM%' => $upstream]); - - // WebSocket — the ONLY place that sets Upgrade/Connection, with long - // timeouts so idle sockets are not culled mid-conversation. - $websocket = ($sw?->websocketPath ?? null) === null ? '' : $this->fill(<<<'NGINX' - - # WebSocket endpoint (OpenSwoole). - location %PATH% { - proxy_pass http://%UPSTREAM%; - proxy_http_version 1.1; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; - - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - } -NGINX, ['%PATH%' => $sw->websocketPath, '%UPSTREAM%' => $upstream]); - - $tpl = <<<'NGINX' -%CACHEPROFILE%%RUNTIME%%PATHS%# Project: %NAME% (OpenSwoole) — the app's env lives in the Swoole process: -# hkm run %NAME% --swoole (bind it to %BIND%) -%UPSTREAMBLOCK% -%REDIRECT%server { - %LISTEN% - server_name %NAMES%; - - root %DOCROOT%; -%SSL% -%LOGS%%HEADERS% server_tokens off; - client_max_body_size 25m; -%METHODS%%RATELIMIT%%COMPRESSION%%DENY% - - # Static assets are served straight off disk from the public root above (a - # miss is a 404 — never forwarded to OpenSwoole). Evaluated AFTER the deny - # rules above so nothing inside a denied path leaks via a whitelisted ext. -%STATIC% -%HEALTH%%WEBSOCKET% - # Normal application traffic → OpenSwoole. No Upgrade/Connection here; the - # WebSocket location above owns that. - location / { - proxy_pass http://%UPSTREAM%; - proxy_http_version 1.1; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Forwarded-Port $server_port; - proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; - - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - - proxy_buffering on; - proxy_buffer_size 4k; - proxy_buffers 8 4k; - proxy_busy_buffers_size 8k; - - proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; - proxy_next_upstream_tries 2; -%PROXYHEADERS% } -%DEVLOC%} -NGINX; - - return $this->fill($tpl, [ - '%NAME%' => $site->name, - '%CACHEPROFILE%' => $c['banner'], - '%RUNTIME%' => $site->model->banner(), - '%PATHS%' => $paths, - '%REDIRECT%' => $redirect, - '%LISTEN%' => $listen, - '%SSL%' => $ssl, - '%NAMES%' => implode(' ', $site->publicDomains), - '%DOCROOT%' => $site->publicRoot(), - '%LOGS%' => $c['logs'], - '%HEADERS%' => $c['serverHeaders'], - '%METHODS%' => $c['methodGuard'], - '%COMPRESSION%' => $c['compression'], - '%RATELIMIT%' => $c['rateLimit'], - '%STATIC%' => $c['static'], - '%DENY%' => $c['deny'], - '%HEALTH%' => $health, - '%WEBSOCKET%' => $websocket, - '%PROXYHEADERS%' => $c['proxyHeaders'], - '%DEVLOC%' => $c['devLoc'], - '%UPSTREAMBLOCK%' => $this->nginxUpstream($site), - '%UPSTREAM%' => $upstream, - '%BIND%' => $sw->upstream(), - ]); - } - - /** - * The nginx listen directive(s) + ssl_certificate block for a mode. - * - * @return array{0: string, 1: string} [listen directives, ssl block ('' when plain)] - */ - private function nginxTls(TlsConfig $tls, int $httpPort, int $sslPort): array - { - if ($tls->mode === TlsMode::None) { - return ["listen {$httpPort};\n listen [::]:{$httpPort};", '']; - } - - // IPv4 + IPv6 listeners, mirroring the plain-HTTP redirect block so an - // IPv6-only client that hits :80 has a :443 to be redirected to. - $listen = "listen {$sslPort} ssl;\n listen [::]:{$sslPort} ssl;\n http2 on;"; - $ssl = "\n ssl_certificate {$tls->cert};\n ssl_certificate_key {$tls->key};\n" - . $this->nginxTlsHardening(); - - return [$listen, $ssl]; - } - - /** - * Explicit TLS pinning + session settings. Emitted for every TLS listener in - * BOTH profiles: relying on the build defaults has historically left TLS - * 1.0/1.1 enabled on some distributions. OCSP stapling stays OFF by default — - * Cloudflare Origin CA certs are not publicly chained, so stapling would fail; - * enable EDGE_SSL_STAPLING only with a publicly-chained cert. - */ - private function nginxTlsHardening(): string - { - $protocols = (string) (edge_config('ssl_hardening.protocols') ?: 'TLSv1.2 TLSv1.3'); - $ciphers = (string) (edge_config('ssl_hardening.ciphers') ?: - 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:' - . 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:' - . 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305'); - - $out = " ssl_protocols {$protocols};\n" - . " ssl_ciphers {$ciphers};\n" - . " ssl_prefer_server_ciphers off;\n" - . " ssl_session_cache shared:SSL:10m;\n" - . " ssl_session_timeout 1h;\n" - . " ssl_session_tickets off;\n"; - - if ((bool) edge_config('ssl_hardening.stapling', false)) { - $out .= " ssl_stapling on;\n ssl_stapling_verify on;\n"; - } - - return $out; - } - - /** - * A plain-HTTP server that 301-redirects to HTTPS (the `both` mode). ACME / - * Let's Encrypt HTTP-01 validation is served BEFORE the redirect so a cert can - * still be issued/renewed over plain :80. - * - * @param list $domains - */ - private function nginxRedirect(array $domains, string $docroot, int $httpPort, int $sslPort): string - { - $names = implode(' ', $domains); - $target = $sslPort === 443 - ? 'https://$host$request_uri' - : "https://\$host:{$sslPort}\$request_uri"; - - return << $sites */ - private function apacheVhosts(array $sites, TlsConfig $tls, int $httpPort, int $sslPort, ServerStack $stack): string - { - $out = "# Managed by the HKM Edge plugin (`hkm edge:apply`). Do NOT edit by hand.\n"; - foreach ($sites as $site) { - if (!$site->servesPublic()) { - continue; - } - $out .= "\n" . $this->apacheSite($site, $tls, $httpPort, $sslPort, $stack); - } - - return rtrim($out, "\n") . "\n"; - } - - private function apacheSite(Site $site, TlsConfig $tls, int $httpPort, int $sslPort, ServerStack $stack): string - { - $aliases = ''; - foreach (array_slice($site->publicDomains, 1) as $d) { - $aliases .= " ServerAlias {$d}\n"; - } - $setenv = ''; - foreach ($site->env as $k => $v) { - $setenv .= sprintf(" SetEnv %s \"%s\"\n", $k, $this->escapeApache($v)); - } - - // PHP handler: FPM via mod_proxy_fcgi, or reverse-proxy for Swoole. - if ($site->model === ServeModel::Swoole) { - $handler = " ProxyPreserveHost On\n ProxyPass / http://{$site->upstream}/\n ProxyPassReverse / http://{$site->upstream}/"; - } else { - $fcgi = str_starts_with($site->upstream, 'unix:') - ? 'proxy:' . $site->upstream . '|fcgi://localhost/' - : 'proxy:fcgi://' . $site->upstream; - $handler = " \n SetHandler \"{$fcgi}\"\n "; - } - - // Plain mode serves on :80; ssl/both serve the app on the TLS port. - $vhostPort = $tls->mode === TlsMode::None ? $httpPort : $sslPort; - $ssl = $tls->mode === TlsMode::None - ? '' - : "\n SSLEngine on\n SSLCertificateFile {$tls->cert}\n SSLCertificateKeyFile {$tls->key}\n"; - $redirect = $tls->mode === TlsMode::Both - ? $this->apacheRedirect($site, $aliases, $httpPort) . "\n\n" - : ''; - - // HSTS on TLS modes (needs mod_headers) + compression (mod_brotli/ - // mod_deflate via mod_filter). Both are gated on the module actually - // being loaded, probed from `apachectl -M`. - $hsts = $tls->mode->usesTls() && $stack->apacheHasModule('headers') ? $this->apacheHsts() : ''; - $compression = $this->apacheCompression($stack); - - $tpl = <<<'APACHE' -# Project: %NAME% -%REDIRECT% - ServerName %PRIMARY% -%ALIASES% DocumentRoot %DOCROOT% - - - AllowOverride All - Require all granted - Options -Indexes +FollowSymLinks - - - Require all denied - -%SSL% -%HSTS%%COMPRESSION%%SETENV%%HANDLER% - - ServerSignature Off - LimitRequestBody 26214400 - -APACHE; - - return $this->fill($tpl, [ - '%NAME%' => $site->name, - '%REDIRECT%' => $redirect, - '%PORT%' => (string) $vhostPort, - '%PRIMARY%' => $site->publicDomains[0] ?? '_', - '%ALIASES%' => $aliases, - '%DOCROOT%' => $site->publicRoot(), - '%SSL%' => $ssl, - '%HSTS%' => $hsts, - '%COMPRESSION%' => $compression, - '%SETENV%' => $setenv, - '%HANDLER%' => $handler, - ]); - } - - /** - * A plain-HTTP VirtualHost that rewrites every request to HTTPS (`both`). - * $aliases is the pre-rendered " ServerAlias …\n" block (may be ''). - */ - private function apacheRedirect(Site $site, string $aliases, int $httpPort): string - { - $primary = $site->publicDomains[0] ?? '_'; - - return rtrim(<< - ServerName {$primary} - {$aliases} RewriteEngine On - RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] - - APACHE); - } - - // ── helpers ─────────────────────────────────────────────────────────────── - - /** @param list $sites @return list */ - private function publicDomains(array $sites): array - { - $domains = []; - foreach ($sites as $site) { - foreach ($site->publicDomains as $d) { - $domains[] = $d; - } - } - $domains = array_values(array_unique($domains)); - sort($domains); - - return $domains; - } - - /** The internal port nginx vhosts listen on when behind the stream splitter. */ - private function nginxInternalPort(): int - { - $backend = (string) edge_config('upstreams.nginx', '127.0.0.1:444'); - $port = (int) substr(strrchr($backend, ':') ?: ':444', 1); - - return $port > 0 ? $port : 444; - } - - /** - * The TLS port the nginx-only vhost LISTENS on. - * - * Standalone that's the public port (443). But when this host ALSO runs an SNI - * `stream {}` router (which binds :443 itself), the vhost must listen on the - * internal backend port (444) or nginx fails to start with "Address already in - * use". Resolution: explicit EDGE_NGINX_SSL_PORT wins; else, if the host has an - * existing stream splitter (detected on the host) is present, use the internal - * backend port; else the public port. - */ - private function nginxSslListenPort(ServerStack $stack): int - { - $override = (int) edge_config('nginx_ssl_port', 0); - if ($override > 0) { - return $override; - } - // Behind an SNI router — forced via config, or auto-detected because the - // host already has a `stream {}` splitter that owns :443. - if ((bool) edge_config('behind_sni_router', false) || $stack->nginxHasStreamConfig) { - return $this->nginxInternalPort(); - } - - return (int) edge_config('listen', 443); - } - - /** MIME types worth compressing (already-compressed images/video excluded). */ - private const COMPRESSIBLE = 'text/plain text/css application/json application/javascript ' - . 'text/xml application/xml application/xml+rss application/rss+xml ' - . 'application/atom+xml application/vnd.ms-fontobject font/ttf font/otf ' - . 'image/svg+xml text/javascript'; - - /** - * Resolve the configured compression preference for a given server, using - * that server's own Brotli availability. `auto` → brotli when available else - * gzip; an explicit `brotli` also degrades to gzip when the module is absent, - * so the emitted config never fails the server's own config test. - */ - private function compressionMode(bool $brotliAvailable): string - { - $mode = (string) edge_config('compression', 'auto'); - if ($mode === 'auto') { - return $brotliAvailable ? 'brotli' : 'gzip'; - } - if ($mode === 'brotli' && !$brotliAvailable) { - return 'gzip'; - } - - return $mode; // brotli (available) | gzip | off - } - - /** nginx gzip/brotli block (server context), gated on nginx's own ngx_brotli. */ - private function nginxCompression(ServerStack $stack): string - { - $types = self::COMPRESSIBLE; - $gzip = " gzip on;\n gzip_vary on;\n gzip_min_length 256;\n" - . " gzip_proxied any;\n gzip_comp_level 6;\n gzip_types {$types};\n"; - $brotli = " brotli on;\n brotli_comp_level 6;\n brotli_min_length 256;\n brotli_types {$types};\n"; - - return match ($this->compressionMode($stack->nginxHasBrotli)) { - 'brotli' => $brotli . $gzip, // gzip fallback for clients without `br` - 'gzip' => $gzip, - default => '', // 'off' → nothing - }; - } - - /** - * Apache compression, gated on the modules actually loaded. `AddOutputFilterByType` - * needs mod_filter; the filters need mod_brotli / mod_deflate. A requested - * mode degrades to whatever the host can do (brotli → deflate → nothing) - * rather than emitting a directive that would fail configtest. - */ - private function apacheCompression(ServerStack $stack): string - { - $types = self::COMPRESSIBLE; - $mode = $this->compressionMode($stack->apacheHasModule('brotli')); - - if ($mode === 'off' || !$stack->apacheHasModule('filter')) { - return ''; - } - - $out = ''; - if ($mode === 'brotli') { // compressionMode() already confirmed mod_brotli - $out .= " AddOutputFilterByType BROTLI_COMPRESS {$types}\n"; - } - // gzip fallback (and the whole gzip mode) whenever mod_deflate is present. - if ($stack->apacheHasModule('deflate')) { - $out .= " AddOutputFilterByType DEFLATE {$types}\n"; - } - - return $out; - } - - /** - * The HSTS Strict-Transport-Security value, or '' when disabled. - * - * The DEVELOPMENT profile emits a deliberately SHORT max-age with NO - * includeSubDomains and NEVER preload: a dev host (often *.local with a - * self-signed cert) must not pin a browser to HTTPS-for-a-year — that is - * near-impossible to undo per-browser and would also suppress the very - * plain-HTTP request the --tls=both redirect exists to catch. PRODUCTION keeps - * the long max-age; `preload` stays opt-in (hsts.preload) because it is very - * hard to reverse and must never be a silent default. - * - * A null profile (the Apache-only path) is treated as production. - */ - private function hstsValue(?CacheProfile $profile = null): string - { - if (!(bool) edge_config('hsts.enabled', true)) { - return ''; - } - - if ($profile?->isDevelopment() === true) { - $devMaxAge = (int) edge_config('hsts.dev_max_age', 300); - - return "max-age={$devMaxAge}"; - } - - $value = 'max-age=' . (int) edge_config('hsts.max_age', 31536000); - if ((bool) edge_config('hsts.include_subdomains', true)) { - $value .= '; includeSubDomains'; - } - if ((bool) edge_config('hsts.preload', false)) { - $value .= '; preload'; - } - - return $value; - } - - /** nginx `add_header Strict-Transport-Security …` at the given indent ('' when off/plain). */ - private function nginxHsts(int $indent, ?CacheProfile $profile = null): string - { - $value = $this->hstsValue($profile); - - return $value === '' ? '' : str_repeat(' ', $indent) . "add_header Strict-Transport-Security \"{$value}\" always;\n"; - } - - /** Apache `Header always set Strict-Transport-Security …` ('' when off/plain). */ - private function apacheHsts(): string - { - $value = $this->hstsValue(); - - return $value === '' ? '' : " Header always set Strict-Transport-Security \"{$value}\"\n"; - } - - /** Render a TTL (seconds) as the cleanest nginx `expires` unit (1y / 30d / 3600s). */ - private function expiresValue(int $seconds): string - { - return match (true) { - $seconds <= 0 => 'off', - $seconds % 31536000 === 0 => intdiv($seconds, 31536000) . 'y', - $seconds % 86400 === 0 => intdiv($seconds, 86400) . 'd', - $seconds % 3600 === 0 => intdiv($seconds, 3600) . 'h', - default => $seconds . 's', - }; - } - - private function escapeNginx(string $v): string - { - return str_replace(['\\', '"'], ['\\\\', '\\"'], $v); - } - - private function escapeApache(string $v): string - { - return str_replace('"', '\\"', $v); - } - - /** @param array $vars */ - private function fill(string $template, array $vars): string - { - return rtrim(strtr($template, $vars), "\n") . "\n"; - } -} diff --git a/plugins/Edge/Infrastructure/HostsFileWriter.php b/plugins/Edge/Infrastructure/HostsFileWriter.php deleted file mode 100644 index fe5be22..0000000 --- a/plugins/Edge/Infrastructure/HostsFileWriter.php +++ /dev/null @@ -1,196 +0,0 @@ ->> HKM Edge (local domains) >>>'; - private const END = '# <<< HKM Edge (local domains) <<<'; - - /** - * @param list $domains local hostnames to point at $ip - * @return array{ok: bool, changed?: bool, dry_run?: bool, path: string, count: int, skipped?: list, block?: string, message?: string} - */ - public function sync(array $domains, string $ip, string $path, bool $remove = false, bool $dryRun = false): array - { - if (!is_file($path)) { - return ['ok' => false, 'path' => $path, 'count' => 0, 'message' => "hosts file not found: {$path}"]; - } - - $current = (string) file_get_contents($path); - $stripped = $this->stripBlock($current); - - if ($remove) { - // --remove clears the ENTIRE managed block; everything else is kept. - $removed = \count($this->parseBlock($current)); - - return $this->write($current, rtrim($stripped, "\n") . "\n", $path, $removed, [], $dryRun, ''); - } - - // MERGE: seed from the domains already in our block, then add new ones. - // Insertion order is preserved (new hosts append) so a no-op run produces - // a byte-identical block and reports "already current". - $blockEntries = $this->parseBlock($current); // [host => ip], in file order - $existing = $this->existingHosts($stripped); // hosts mapped OUTSIDE our block - - $added = []; - $skipped = []; - foreach ($domains as $d) { - $key = strtolower(trim($d)); - if ($key === '') { - continue; - } - if (isset($existing[$key])) { - $skipped[] = $d; // hand-added elsewhere — leave authoritative - continue; - } - if (!isset($blockEntries[$key])) { - $added[] = $d; // genuinely new to the block - } - $blockEntries[$key] = $ip; // add, or re-point to the current ip - } - - $block = $this->renderBlock($blockEntries); - $new = $block === '' - ? rtrim($stripped, "\n") . "\n" - : rtrim($stripped, "\n") . "\n\n" . $block . "\n"; - - return $this->write($current, $new, $path, \count($added), $skipped, $dryRun, $block); - } - - /** - * Compute the result envelope (and perform the write unless $dryRun). - * - * @param list $skipped - * @return array{ok: bool, changed?: bool, dry_run?: bool, path: string, count: int, skipped?: list, block?: string, message?: string} - */ - private function write(string $current, string $new, string $path, int $count, array $skipped, bool $dryRun, string $block): array - { - $changed = $new !== $current; - - if ($dryRun) { - return ['ok' => true, 'dry_run' => true, 'changed' => $changed, 'path' => $path, 'count' => $count, 'skipped' => $skipped, 'block' => $block]; - } - if (!$changed) { - return ['ok' => true, 'changed' => false, 'path' => $path, 'count' => $count, 'skipped' => $skipped, 'message' => 'already up to date']; - } - - $tmp = $path . '.hkm.tmp'; - if (@file_put_contents($tmp, $new) === false || !@rename($tmp, $path)) { - @unlink($tmp); - return ['ok' => false, 'path' => $path, 'count' => $count, 'skipped' => $skipped, 'message' => "cannot write {$path} (run with the privileges to edit it, e.g. sudo)"]; - } - - return ['ok' => true, 'changed' => true, 'path' => $path, 'count' => $count, 'skipped' => $skipped]; - } - - /** Remove any existing HKM-managed block (and the blank lines around it). */ - private function stripBlock(string $contents): string - { - $pattern = '/\n*' . preg_quote(self::BEGIN, '/') . '.*?' . preg_quote(self::END, '/') . '\n*/s'; - - return preg_replace($pattern, "\n", $contents) ?? $contents; - } - - /** - * The (host => ip) entries currently inside OUR managed block, in file order. - * Empty when the block is absent — the merge then starts fresh. - * - * @return array - */ - private function parseBlock(string $contents): array - { - if (preg_match('/' . preg_quote(self::BEGIN, '/') . '(.*?)' . preg_quote(self::END, '/') . '/s', $contents, $m) !== 1) { - return []; - } - - $entries = []; - foreach (explode("\n", $m[1]) as $line) { - $line = trim($line); - if ($line === '' || str_starts_with($line, '#')) { - continue; - } - $parts = preg_split('/\s+/', $line) ?: []; - if (\count($parts) < 2) { - continue; - } - $lineIp = $parts[0]; - foreach (\array_slice($parts, 1) as $name) { - $name = strtolower(trim($name)); - if ($name !== '') { - $entries[$name] = $lineIp; - } - } - } - - return $entries; - } - - /** - * Render the managed block from (host => ip) entries, or '' when empty. - * - * @param array $entries - */ - private function renderBlock(array $entries): string - { - if ($entries === []) { - return ''; - } - - $lines = [self::BEGIN]; - foreach ($entries as $host => $ip) { - $lines[] = sprintf('%s %s', $ip, $host); - } - $lines[] = self::END; - - return implode("\n", $lines); - } - - /** - * Every hostname already mapped in the file (outside our managed block), as a - * lowercase lookup set — so an entry the user added by hand is never - * duplicated or overridden. - * - * @return array - */ - private function existingHosts(string $contents): array - { - $hosts = []; - foreach (explode("\n", $contents) as $line) { - $line = trim($line); - if ($line === '' || str_starts_with($line, '#')) { - continue; - } - // Drop any trailing comment, then split "IP host [host…]". - if (($hash = strpos($line, '#')) !== false) { - $line = trim(substr($line, 0, $hash)); - } - $parts = preg_split('/\s+/', $line) ?: []; - if (\count($parts) < 2) { - continue; - } - foreach (\array_slice($parts, 1) as $name) { - $name = strtolower(trim($name)); - if ($name !== '') { - $hosts[$name] = true; - } - } - } - - return $hosts; - } -} diff --git a/plugins/Edge/Infrastructure/ServiceRenderer.php b/plugins/Edge/Infrastructure/ServiceRenderer.php deleted file mode 100644 index 5ec6e7b..0000000 --- a/plugins/Edge/Infrastructure/ServiceRenderer.php +++ /dev/null @@ -1,168 +0,0 @@ -.service (WantedBy=multi-user.target) - * supervisor → [program:hkm-] block - * - * PHP-FPM sites have no unit — php-fpm already manages those workers. - */ -final class ServiceRenderer -{ - /** Unit/program name for a project, e.g. `hkm-blog`. */ - public function unitName(Site $site): string - { - return 'hkm-' . preg_replace('/[^a-z0-9._-]+/i', '-', $site->name); - } - - /** Does this site need a process-manager unit at all? */ - public function supports(Site $site): bool - { - return $site->model === ServeModel::Swoole && $site->swoole !== null; - } - - /** - * A systemd unit. $user/$group default to www-data; env is injected as - * Environment= lines so the Swoole process boots with the same run-env the - * FPM vhost would have received. - */ - public function systemd(Site $site, string $user = 'www-data', string $group = 'www-data'): string - { - $sw = $site->swoole; - $root = $site->root !== '' ? $site->root : dirname($site->docroot, 2); - $exec = $this->execStart($site); - - $env = ''; - foreach ($this->serviceEnv($site) as $k => $v) { - $env .= sprintf("Environment=%s=%s\n", $k, $this->escapeSystemd($v)); - } - - $tpl = <<<'UNIT' -# Managed by the HKM Edge plugin (`hkm edge:service`). Do NOT edit by hand. -# OpenSwoole application server for "%NAME%" — nginx reverse-proxies to %UPSTREAM%. -[Unit] -Description=HKM OpenSwoole server for %NAME% -After=network.target - -[Service] -Type=simple -User=%USER% -Group=%GROUP% -WorkingDirectory=%ROOT% -%ENV%ExecStart=%EXEC% -ExecReload=/bin/kill -USR1 $MAINPID -Restart=always -RestartSec=5 -KillSignal=SIGTERM -TimeoutStopSec=30 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target -UNIT; - - return $this->fill($tpl, [ - '%NAME%' => $site->name, - '%UPSTREAM%' => $sw->upstream(), - '%USER%' => $user, - '%GROUP%' => $group, - '%ROOT%' => $root, - '%ENV%' => $env, - '%EXEC%' => $exec, - ]); - } - - /** A supervisor program block. */ - public function supervisor(Site $site, string $user = 'www-data'): string - { - $root = $site->root !== '' ? $site->root : dirname($site->docroot, 2); - $name = $this->unitName($site); - - $env = []; - foreach ($this->serviceEnv($site) as $k => $v) { - $env[] = $k . '="' . str_replace('"', '\"', $v) . '"'; - } - $envLine = $env === [] ? '' : 'environment=' . implode(',', $env) . "\n"; - - $tpl = <<<'UNIT' -; Managed by the HKM Edge plugin (`hkm edge:service`). Do NOT edit by hand. -; OpenSwoole application server for "%NAME%" — nginx reverse-proxies to %UPSTREAM%. -[program:%UNIT%] -command=%EXEC% -directory=%ROOT% -user=%USER% -%ENV%autostart=true -autorestart=true -startsecs=5 -stopsignal=TERM -stopwaitsecs=30 -redirect_stderr=true -stdout_logfile=/var/log/supervisor/%UNIT%.log -UNIT; - - return $this->fill($tpl, [ - '%NAME%' => $site->name, - '%UNIT%' => $name, - '%UPSTREAM%' => $site->swoole->upstream(), - '%EXEC%' => $this->execStart($site), - '%ROOT%' => $root, - '%USER%' => $user, - '%ENV%' => $envLine, - ]); - } - - /** ` ` — absolute, so systemd never depends on $PATH. */ - private function execStart(Site $site): string - { - $sw = $site->swoole; - $root = $site->root !== '' ? $site->root : dirname($site->docroot, 2); - $cmd = str_starts_with($sw->command, '/') ? $sw->command : $root . '/' . ltrim($sw->command, '/'); - - return $sw->php . ' ' . $cmd; - } - - /** - * Env for the Swoole process: the site's run-env (APP_ENV + kernel - * resolution) plus the bind host/port and worker count, so the server can - * read them instead of hard-coding. - * - * @return array - */ - private function serviceEnv(Site $site): array - { - $sw = $site->swoole; - $env = $site->env; - - $env['SWOOLE_HOST'] = $sw->host; - $env['SWOOLE_PORT'] = (string) $sw->port; - if (strtolower($sw->workers) !== 'auto' && $sw->workers !== '') { - $env['SWOOLE_WORKERS'] = $sw->workers; - } - - return $env; - } - - /** systemd Environment= values: keep it single-line and quoted when needed. */ - private function escapeSystemd(string $v): string - { - $v = str_replace(["\n", "\r"], ' ', $v); - - return str_contains($v, ' ') ? '"' . str_replace('"', '\"', $v) . '"' : $v; - } - - /** @param array $vars */ - private function fill(string $template, array $vars): string - { - return rtrim(strtr($template, $vars), "\n") . "\n"; - } -} diff --git a/plugins/Edge/Infrastructure/SiteCollector.php b/plugins/Edge/Infrastructure/SiteCollector.php deleted file mode 100644 index c934a2a..0000000 --- a/plugins/Edge/Infrastructure/SiteCollector.php +++ /dev/null @@ -1,311 +0,0 @@ - - */ - public function sites(bool $all = false, ?string $appEnv = null): array - { - if (!$all) { - $site = $this->currentSite($appEnv); - - return $site !== null ? [$site] : []; - } - - $sites = []; - foreach ($this->projects() as $name => $project) { - $site = $this->buildSite((string) $name, (string) ($project['path'] ?? ''), (array) ($project['domains'] ?? []), $appEnv); - if ($site !== null) { - $sites[] = $site; - } - } - - return $sites; - } - - /** Local (dev-only) domains for the current project (or all projects). */ - public function localDomains(bool $all = false): array - { - $local = []; - foreach ($this->sites($all) as $site) { - foreach ($site->localDomains as $d) { - $local[] = $d; - } - } - if ($all) { - foreach ($this->classify((array) edge_config('extra_domains', []))['local'] as $d) { - $local[] = $d; - } - } - $local = array_values(array_unique($local)); - sort($local); - - return $local; - } - - /** The project the command is running in — its own proj.json is the truth. */ - private function currentSite(?string $appEnv = null): ?Site - { - $path = rtrim((string) base_path(), '/'); - $proj = $this->projJson($path); - $name = (string) ($proj['name'] ?? basename($path)); - - return $this->buildSite($name, $path, (array) ($proj['domains'] ?? []), $appEnv); - } - - /** @param array $domains */ - private function buildSite(string $name, string $path, array $domains, ?string $appEnv = null): ?Site - { - $path = rtrim($path, '/'); - $cls = $this->classify($domains); - if ($path === '' || ($cls['public'] === [] && $cls['local'] === [])) { - return null; - } - - $edge = (array) ($this->projJson($path)['edge'] ?? []); - // `runtime` is the current spelling; `serve` is kept for older proj.json - // files. Both accept php-fpm|openswoole as well as fpm|swoole. - $model = ServeModel::from_((string) ( - $edge['runtime'] ?? $edge['serve'] ?? edge_config('serve.model', 'fpm') - )); - $swoole = $model === ServeModel::Swoole ? $this->swooleOptions($edge) : null; - - // In dev mode (`hkm … --dev` → HKM_DEV=1) — or when EDGE_LOCAL_IN_SERVER - // is forced — the local (.local/.test) domains are ALSO served by the - // vhost, not just written to /etc/hosts. A production run keeps them out - // (public domains resolve through DNS). Local domains still feed /etc/hosts - // in both cases, so the loopback mapping is present for the served vhost. - $public = $this->serveLocalInServer() - ? array_values(array_unique([...$cls['public'], ...$cls['local']])) - : $cls['public']; - - return new Site( - name: $name, - docroot: $path . '/app/public', - publicDomains: $public, - localDomains: $cls['local'], - model: $model, - upstream: $swoole?->upstream() ?? $this->upstream($model, $edge), - env: $this->env($edge, $appEnv), - swoole: $swoole, - root: $path, - ); - } - - // ── registries ──────────────────────────────────────────────────────────── - - /** @return array> */ - private function projects(): array - { - $file = (string) edge_config('projects_registry', ''); - if ($file === '' || !is_file($file)) { - return []; - } - $json = json_decode((string) file_get_contents($file), true); - - return is_array($json) ? $json : []; - } - - /** @return array */ - private function projJson(string $path): array - { - $file = $path . '/proj.json'; - if (!is_file($file)) { - return []; - } - $json = json_decode((string) file_get_contents($file), true); - - return is_array($json) ? $json : []; - } - - // ── serving + env ─────────────────────────────────────────────────────── - - /** - * Should local (.local/.test) domains be served by the vhost — not just - * written to /etc/hosts? True in dev mode (the launcher exports HKM_DEV=1 for - * `--dev`) or when EDGE_LOCAL_IN_SERVER is explicitly forced. False for a - * production run, where public domains resolve through DNS. - */ - private function serveLocalInServer(): bool - { - if (filter_var(\env('HKM_DEV', 'false'), FILTER_VALIDATE_BOOL)) { - return true; - } - - return (bool) edge_config('include_local_in_server', false); - } - - /** - * Per-project OpenSwoole settings: config defaults, overridden by the - * project's proj.json `edge.openswoole` block (or the flat `edge.port`). - * - * @param array $edge - */ - private function swooleOptions(array $edge): SwooleOptions - { - $o = (array) ($edge['openswoole'] ?? $edge['swoole'] ?? []); - - // A null/empty path disables the block; omitted falls back to config. - $ws = array_key_exists('websocket', $o) - ? (is_string($o['websocket']) && trim($o['websocket']) !== '' ? trim($o['websocket']) : null) - : (string) edge_config('serve.websocket_path', '/ws'); - $health = array_key_exists('health', $o) - ? (is_string($o['health']) && trim($o['health']) !== '' ? trim($o['health']) : null) - : ((bool) edge_config('serve.health.enabled', false) - ? (string) edge_config('serve.health.path', '/health') - : null); - - // `ports: [9501, 9502, 9503]` spins one upstream server per port; the - // first is the primary, the rest become extra backends. - $host = (string) ($o['host'] ?? edge_config('serve.swoole_host', '127.0.0.1')); - $ports = array_values(array_filter(array_map('intval', (array) ($o['ports'] ?? [])))); - $port = $ports[0] ?? (int) ($o['port'] ?? $edge['port'] ?? edge_config('serve.swoole_port', 9501)); - $extra = array_map(static fn (int $p): string => "{$host}:{$p}", array_slice($ports, 1)); - - return new SwooleOptions( - host: $host, - port: $port, - websocketPath: $ws === '' ? null : $ws, - healthPath: $health === '' ? null : $health, - php: (string) ($o['php'] ?? edge_config('serve.swoole_php', '/usr/bin/php')), - command: (string) ($o['command'] ?? edge_config('serve.swoole_command', 'bin/server.php')), - workers: (string) ($o['workers'] ?? edge_config('serve.swoole_workers', 'auto')), - extraServers: array_values(array_unique([...$extra, ...array_filter(array_map( - static fn ($v): string => trim((string) $v), - (array) ($o['servers'] ?? []) - ))])), - balance: (string) ($o['balance'] ?? edge_config('serve.swoole_balance', 'least_conn')), - maxFails: (int) ($o['max_fails'] ?? edge_config('serve.swoole_max_fails', 3)), - failTimeout: (string) ($o['fail_timeout'] ?? edge_config('serve.swoole_fail_timeout', '30s')), - keepalive: (int) ($o['keepalive'] ?? edge_config('serve.swoole_keepalive', 32)), - keepaliveTimeout: (string) ($o['keepalive_timeout'] ?? edge_config('serve.swoole_keepalive_timeout', '60s')), - keepaliveRequests: (int) ($o['keepalive_requests'] ?? edge_config('serve.swoole_keepalive_requests', 1000)), - ); - } - - /** @param array $edge */ - private function upstream(ServeModel $model, array $edge): string - { - if ($model === ServeModel::Swoole) { - // Normally supplied by SwooleOptions::upstream(); kept as a fallback. - $host = (string) edge_config('serve.swoole_host', '127.0.0.1'); - $port = (int) ($edge['port'] ?? edge_config('serve.swoole_port', 9501)); - - return "{$host}:{$port}"; - } - - // FPM: an explicit per-project socket, else an explicit EDGE_FPM_SOCKET, - // else auto-resolve the socket matching the CLI PHP version (multi-PHP hosts). - $explicit = (string) ($edge['socket'] ?? edge_config('serve.fpm_socket', '')); - - return $explicit !== '' ? $explicit : $this->probe->phpFpmSocket(); - } - - /** - * The run-env injected into a site's vhost. Base env (APP_ENV, userdata, - * kernel resolution) merged with per-project proj.json `edge.env` extras. - * - * @param array $edge - * @return array - */ - private function env(array $edge, ?string $appEnv = null): array - { - $env = []; - - $appEnv = (string) ($appEnv ?? edge_config('app_env', 'production')); - if ($appEnv !== '') { - $env['APP_ENV'] = $appEnv; - } - - // Pass through the kernel-resolution env the launcher already exported for - // the active context (dev vs live). We read it straight from the process - // environment — no deriving, no defaulting. FPM workers don't inherit it, - // so the vhost must carry whatever `hkm` set. - if ((bool) edge_config('inject_kernel_env', true)) { - foreach ((array) edge_config('kernel_env_keys', []) as $key) { - $value = (string) \env((string) $key, ''); - if ($value !== '') { - $env[(string) $key] = $value; - } - } - } - - // Per-project extras win. - foreach ((array) ($edge['env'] ?? []) as $k => $v) { - if (is_string($k)) { - $env[$k] = (string) $v; - } - } - - return $env; - } - - // ── domain classification (validated) ───────────────────────────────────── - - /** - * @param array $domains - * @return array{public: list, local: list} - */ - private function classify(array $domains): array - { - $exclude = array_map('strtolower', (array) edge_config('exclude_domains', [])); - $public = []; - $local = []; - foreach ($domains as $domain) { - $host = strtolower(trim((string) $domain)); - if ($host === '' || !$this->isValid($host) || in_array($host, $exclude, true)) { - continue; - } - if ($this->isLocal($host)) { - $local[] = $host; - } else { - $public[] = $host; - } - } - - return ['public' => array_values(array_unique($public)), 'local' => array_values(array_unique($local))]; - } - - private function isValid(string $host): bool - { - if (preg_match('/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $host)) { - return true; - } - - return (bool) preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $host); - } - - private function isLocal(string $host): bool - { - if (!str_contains($host, '.')) { - return true; - } - $tld = strtolower(substr((string) strrchr($host, '.'), 1)); - $tlds = array_map('strtolower', (array) edge_config('local_tlds', ['local', 'test', 'localhost', 'example', 'invalid'])); - - return in_array($tld, $tlds, true); - } -} diff --git a/plugins/Edge/Infrastructure/StreamConfigWriter.php b/plugins/Edge/Infrastructure/StreamConfigWriter.php deleted file mode 100644 index 9e176b0..0000000 --- a/plugins/Edge/Infrastructure/StreamConfigWriter.php +++ /dev/null @@ -1,219 +0,0 @@ ->> HKM Edge (managed domains) >>> - * app.migratetravel.com nginx_backend; - * # <<< HKM Edge (managed domains) <<< - * default apache_ssl; - * } - * - * The managed sub-block is fully rewritten each run, so re-applies never - * duplicate; a domain already present anywhere in the map (yours or ours) is - * left untouched. Nothing outside the map is modified. - */ -final class StreamConfigWriter -{ - private const BEGIN = '# >>> HKM Edge (managed domains) >>>'; - private const END = '# <<< HKM Edge (managed domains) <<<'; - - /** - * Insert the missing $domains into the ssl_preread map inside $file. - * - * @param list $domains platform public domains → nginx_backend - * @return array{ - * ok: bool, file: string, added: list, present: list, - * changed: bool, dry_run?: bool, contents?: string, message?: string - * } - */ - public function merge(string $file, array $domains, string $backend = 'nginx_backend', bool $dryRun = false): array - { - $result = ['ok' => false, 'file' => $file, 'added' => [], 'present' => [], 'changed' => false]; - - $original = @file_get_contents($file); - if ($original === false) { - return [...$result, 'message' => "cannot read {$file}"]; - } - - // Isolate the map block. Value var is captured so we tolerate any name. - if (!preg_match('/map\s+\$ssl_preread_server_name\s+\$\w+\s*\{/', $original, $m, PREG_OFFSET_CAPTURE)) { - return [...$result, 'message' => 'no `map $ssl_preread_server_name` block found in ' . $file]; - } - $openPos = (int) $m[0][1]; - $bracePos = strpos($original, '{', $openPos); - if ($bracePos === false) { - return [...$result, 'message' => 'malformed map block in ' . $file]; - } - $closePos = $this->matchingBrace($original, $bracePos); - if ($closePos === null) { - return [...$result, 'message' => 'unbalanced braces in the map block in ' . $file]; - } - - $body = substr($original, $bracePos + 1, $closePos - $bracePos - 1); - - // Drop any previous HKM-managed sub-block, then read the domains that - // remain (yours + any non-managed entries) so we never re-add them. - $bodyNoManaged = $this->stripManaged($body); - $existing = $this->existingHosts($bodyNoManaged); - - [$added, $present] = [[], []]; - foreach ($this->normaliseDomains($domains) as $d) { - if (isset($existing[$d])) { - $present[] = $d; - } else { - $added[] = $d; - $existing[$d] = true; // guard against dupes within $domains - } - } - - $result['added'] = $added; - $result['present'] = $present; - - $indent = $this->detectIndent($bodyNoManaged); - $newBody = $added === [] - ? $bodyNoManaged // nothing to add — just the cleaned body - : $this->insertManagedBlock($bodyNoManaged, $added, $backend, $indent); - - $updated = substr($original, 0, $bracePos + 1) . $newBody . substr($original, $closePos); - $changed = $updated !== $original; - $result['changed'] = $changed; - - if ($dryRun) { - return [...$result, 'ok' => true, 'dry_run' => true, 'contents' => $updated]; - } - if (!$changed) { - return [...$result, 'ok' => true]; // already current - } - - // Atomic write (temp + rename) so a live `include` never sees a partial file. - $tmp = $file . '.hkm.tmp'; - if (@file_put_contents($tmp, $updated) === false || !@rename($tmp, $file)) { - @unlink($tmp); - return [...$result, 'message' => "failed to write {$file} (need sudo?)"]; - } - - return [...$result, 'ok' => true]; - } - - /** Find the `}` matching the `{` at $open, or null if unbalanced. */ - private function matchingBrace(string $s, int $open): ?int - { - $depth = 0; - $len = strlen($s); - for ($i = $open; $i < $len; $i++) { - $c = $s[$i]; - if ($c === '{') { - $depth++; - } elseif ($c === '}') { - if (--$depth === 0) { - return $i; - } - } - } - - return null; - } - - /** Remove a previous `# >>> … >>>` … `# <<< … <<<` managed sub-block. */ - private function stripManaged(string $body): string - { - $pattern = '/[ \t]*' . preg_quote(self::BEGIN, '/') . '.*?' . preg_quote(self::END, '/') . "[ \t]*\r?\n?/s"; - - return (string) preg_replace($pattern, '', $body); - } - - /** - * The hostnames already keyed in the map body (excluding `default`), as a - * lookup set. A map line is ` ;`. - * - * @return array - */ - private function existingHosts(string $body): array - { - $hosts = []; - foreach (explode("\n", $body) as $line) { - $line = trim($line); - if ($line === '' || $line[0] === '#') { - continue; - } - if (preg_match('/^(\S+)\s+\S+\s*;/', $line, $m) && strtolower($m[1]) !== 'default') { - $hosts[strtolower($m[1])] = true; - } - } - - return $hosts; - } - - /** Lowercase, de-duplicate, drop empties/wildcards from the incoming domains. @param list $domains @return list */ - private function normaliseDomains(array $domains): array - { - $seen = []; - foreach ($domains as $d) { - $d = strtolower(trim($d)); - if ($d !== '' && !str_contains($d, '*') && !isset($seen[$d])) { - $seen[$d] = true; - } - } - - return array_keys($seen); - } - - /** The leading whitespace used by existing map entries (fallback: 8 spaces). */ - private function detectIndent(string $body): string - { - foreach (explode("\n", $body) as $line) { - if (trim($line) !== '' && preg_match('/^(\s+)\S/', $line, $m)) { - return $m[1]; - } - } - - return str_repeat(' ', 8); - } - - /** - * Insert the managed sub-block (the missing domains) just before the `default` - * line, or before the closing brace when there is no `default`. - * - * @param list $added - */ - private function insertManagedBlock(string $body, array $added, string $backend, string $indent): string - { - // Column-align the backend like the user's block: pad to the longest host. - $width = max(array_map('strlen', $added)); - $lines = [$indent . self::BEGIN]; - foreach ($added as $host) { - $pad = str_repeat(' ', max(1, $width - strlen($host) + 4)); - $lines[] = $indent . $host . $pad . $backend . ';'; - } - $lines[] = $indent . self::END; - $block = implode("\n", $lines) . "\n"; - - // Prefer to sit right above the `default …;` line. - $out = preg_replace( - '/^([ \t]*default\s+\S+\s*;.*)$/m', - $block . '$1', - $body, - 1, - $count, - ); - if ($count === 1 && $out !== null) { - return $out; - } - - // No default line — append before the (already-excluded) closing brace, - // i.e. at the end of the body, keeping a trailing newline. - return rtrim($body, "\n") . "\n" . $block; - } -} diff --git a/plugins/Edge/Infrastructure/SystemProbe.php b/plugins/Edge/Infrastructure/SystemProbe.php deleted file mode 100644 index 00b4bca..0000000 --- a/plugins/Edge/Infrastructure/SystemProbe.php +++ /dev/null @@ -1,262 +0,0 @@ -which('nginx'); - $apacheInstalled = $this->which('apache2') || $this->which('httpd') || $this->which('apachectl'); - - return new ServerStack( - nginxInstalled: $nginxInstalled, - nginxActive: $this->active('nginx'), - nginxHasStream: $nginxInstalled && $this->nginxHasStream(), - apacheInstalled: $apacheInstalled, - apacheActive: $this->active('apache2') || $this->active('httpd'), - nginxHasBrotli: $nginxInstalled && $this->nginxHasBrotli(), - apacheModules: $apacheInstalled ? $this->apacheModules() : [], - nginxHasStreamConfig: $nginxInstalled && $this->nginxStreamConfigExists((string) edge_config('paths.stream', '')), - ); - } - - /** - * Does the RUNNING nginx already declare an SNI stream splitter (a `stream {}` - * block using `ssl_preread`) in a config file OTHER than the one Edge manages? - */ - private function nginxStreamConfigExists(string $ownPath): bool - { - return $this->nginxStreamConfigFile($ownPath) !== null; - } - - /** - * The ON-DISK path of the config file that holds the RUNNING nginx's SNI - * stream splitter (the `map $ssl_preread_server_name … { … }`), or null when - * none exists — so Edge can UPDATE that file's map in place instead of writing - * a second, conflicting splitter. - * - * `nginx -T` dumps the full, resolved config, prefixing each file with a - * `# configuration file :` marker. We walk it file-by-file, skip Edge's - * own managed file (so re-runs never match themselves), and return the first - * OTHER file that declares the ssl_preread map. `ssl_preread` and that map only - * ever appear inside a stream server, so their presence is a reliable signal. - */ - public function nginxStreamConfigFile(string $ownPath): ?string - { - [$code, $dump] = $this->run('nginx -T'); - if ($code !== 0 || trim($dump) === '') { - return null; - } - - $own = ($ownPath !== '' ? (realpath($ownPath) ?: $ownPath) : ''); - - $current = ''; - $byFile = []; - foreach (explode("\n", $dump) as $line) { - if (preg_match('/^#\s*configuration file\s+(.+):\s*$/', $line, $m)) { - $current = trim($m[1]); - $byFile[$current] ??= ''; - continue; - } - if ($current !== '') { - $byFile[$current] .= $line . "\n"; - } - } - - foreach ($byFile as $path => $body) { - if ($own !== '' && (realpath($path) ?: $path) === $own) { - continue; // Edge's own managed file — not a pre-existing config - } - // The map is the splitter's routing table; ssl_preread confirms it's a - // real SNI stream server and not an incidental mention. - if (str_contains($body, 'ssl_preread') && preg_match('/map\s+\$ssl_preread_server_name\s+\$\w+\s*\{/', $body)) { - // Only files that still exist on disk can be updated in place. - if (is_file($path) && is_writable($path)) { - return $path; - } - if (is_file($path)) { - return $path; // exists but not writable — caller reports "need sudo" - } - } - } - - return null; - } - - /** - * The Apache modules currently LOADED, as short names (no `_module` suffix), - * parsed from `apachectl -M`. Empty list = couldn't probe (caller treats - * that as "unknown", not "absent"). Tries the common front-ends in turn. - */ - public function apacheModules(): array - { - foreach (['apache2ctl -M', 'apachectl -M', 'httpd -M'] as $cmd) { - [$code, $out] = $this->run($cmd); - if ($code !== 0 || trim($out) === '') { - continue; - } - // Lines look like " headers_module (shared)"; grab the module name. - preg_match_all('/^\s*(\w+)_module\b/m', $out, $m); - if ($m[1] !== []) { - return array_values(array_unique($m[1])); - } - } - - return []; - } - - /** The PHP version running THIS command, e.g. "8.4". */ - public function phpCliVersion(): string - { - return PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; - } - - /** - * Resolve the PHP-FPM upstream that matches the CLI PHP version running the - * command, so a multi-PHP host binds the vhost to the RIGHT pool: - * 1. the versioned socket for the CLI version (Debian/Ubuntu naming), - * 2. any versioned socket present — the exact version, else the newest, - * 3. a generic/unversioned socket (RHEL, custom), - * 4. a TCP fallback (127.0.0.1:9000, common in containers). - */ - public function phpFpmSocket(): string - { - $ver = $this->phpCliVersion(); - - foreach (["/run/php/php{$ver}-fpm.sock", "/var/run/php/php{$ver}-fpm.sock"] as $sock) { - if (@file_exists($sock)) { - return "unix:{$sock}"; - } - } - - $socks = array_merge(glob('/run/php/php*-fpm.sock') ?: [], glob('/var/run/php/php*-fpm.sock') ?: []); - if ($socks !== []) { - // exact CLI version wins; otherwise the newest available pool. - usort($socks, fn (string $a, string $b): int => version_compare($this->sockVersion($b), $this->sockVersion($a))); - foreach ($socks as $s) { - if ($this->sockVersion($s) === $ver) { - return "unix:{$s}"; - } - } - return "unix:{$socks[0]}"; - } - - foreach (['/run/php-fpm/www.sock', '/var/run/php-fpm/www.sock', '/run/php/php-fpm.sock'] as $sock) { - if (@file_exists($sock)) { - return "unix:{$sock}"; - } - } - - return '127.0.0.1:9000'; - } - - /** Which php*-fpm services systemd reports as active (best-effort, for status). */ - public function phpFpmActive(): array - { - [$code, $out] = $this->run("systemctl list-units --type=service --state=active --no-legend 'php*-fpm*.service'"); - if ($code !== 0 || trim($out) === '') { - return []; - } - $names = []; - foreach (explode("\n", trim($out)) as $line) { - if (preg_match('/(php[0-9.]*-fpm[^\s]*)\.service/', $line, $m)) { - $names[] = $m[1]; - } - } - - return array_values(array_unique($names)); - } - - private function sockVersion(string $path): string - { - return preg_match('/php(\d+\.\d+)-fpm\.sock$/', $path, $m) ? $m[1] : '0'; - } - - /** Run an arbitrary command; returns [exitCode, combinedOutput]. */ - public function run(string $command): array - { - $output = []; - $code = 0; - @exec($command . ' 2>&1', $output, $code); - - return [$code, implode("\n", $output)]; - } - - private function which(string $binary): bool - { - [$code] = $this->run('command -v ' . escapeshellarg($binary)); - - return $code === 0; - } - - /** - * Is a service active? Prefer systemd; fall back to a process match so it - * still works on non-systemd hosts / inside containers. - */ - private function active(string $service): bool - { - [$code, $out] = $this->run('systemctl is-active ' . escapeshellarg($service)); - if ($code === 0 && trim($out) === 'active') { - return true; - } - - [$pcode] = $this->run('pgrep -x ' . escapeshellarg($service)); - - return $pcode === 0; - } - - /** Does the installed nginx support the stream (L4) module? */ - private function nginxHasStream(): bool - { - [, $banner] = $this->run('nginx -V'); - if (str_contains($banner, '--with-stream')) { - return true; - } - - // Dynamic module shipped separately (Debian/RHEL common paths). - foreach ([ - '/usr/lib/nginx/modules/ngx_stream_module.so', - '/usr/lib64/nginx/modules/ngx_stream_module.so', - '/etc/nginx/modules/ngx_stream_module.so', - ] as $path) { - if (is_file($path)) { - return true; - } - } - - return false; - } - - /** Was the installed nginx built with (or shipped) the ngx_brotli module? */ - private function nginxHasBrotli(): bool - { - [, $banner] = $this->run('nginx -V'); - if (str_contains($banner, 'brotli')) { - return true; - } - - foreach ([ - '/usr/lib/nginx/modules/ngx_http_brotli_filter_module.so', - '/usr/lib64/nginx/modules/ngx_http_brotli_filter_module.so', - '/etc/nginx/modules/ngx_http_brotli_filter_module.so', - ] as $path) { - if (is_file($path)) { - return true; - } - } - - return false; - } -} diff --git a/plugins/Edge/Provider.php b/plugins/Edge/Provider.php deleted file mode 100644 index 97092db..0000000 --- a/plugins/Edge/Provider.php +++ /dev/null @@ -1,83 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [EdgeServiceContract::class]; - } - - public function register(ModuleContainer $container): void - { - $container->bind(EdgeServiceContract::class, static fn (): EdgeService => self::service()); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // CLI-only: defer so only CLI processes construct the commands. - $cli->defer(static function (CliPipeline $cli): void { - $service = self::service(); - $cli->command(new EdgeStatusCommand($service)); - $cli->command(new EdgeApplyCommand($service)); - $cli->command(new EdgeHostsCommand($service)); - $cli->command(new EdgeServiceCommand($service)); - }); - } - - private static function service(): EdgeService - { - $probe = new SystemProbe(); - - return new EdgeService( - $probe, - new SiteCollector($probe), - new ConfigRenderer(), - new HostsFileWriter(), - new ServiceRenderer(), - ); - } -} diff --git a/plugins/Edge/README.md b/plugins/Edge/README.md deleted file mode 100644 index 40d1bc3..0000000 --- a/plugins/Edge/README.md +++ /dev/null @@ -1,267 +0,0 @@ -# Edge plugin (`Plugins\Edge`, solves `edge.routing`) - -Generates the host's **web-server front config** from the platform's registered -domains, adapting to whatever is actually running on the machine. - -It probes the host, picks a strategy, renders the matching config, then -validates and reloads the server. - -## Strategy detection - -| Detected stack | Strategy | Rendered config | -|---|---|---| -| nginx **and** Apache active, nginx has the `stream` module | `nginx-stream` | nginx **SNI (L4) stream splitter** — listed domains → nginx (`:444`), everything else → Apache (`:8443`) | -| only nginx active (or Apache present but **inactive**, or nginx lacks `stream`) | `nginx-only` | plain nginx reverse-proxy vhost (no stream) | -| only Apache active | `apache-only` | Apache SSL `VirtualHost` | -| neither active | `none` | nothing — reports and stops | - -This is exactly the "check what's on the host and apply accordingly" rule: -nginx is the front; if it can stream and Apache is up, split by SNI; if Apache -is down, just nginx without stream; if only Apache, configure Apache. - -### Reusing (and updating) an existing nginx stream splitter - -When both servers are running **and** the running nginx **already declares an -SNI `stream {}` splitter** (a `map $ssl_preread_server_name … { … }` using -`ssl_preread`, located from `nginx -T`, excluding Edge's own managed file), Edge -does **not** write a second, conflicting splitter. Instead it: - -1. emits only the internal backend vhosts (TLS-terminating on `:444`), and -2. **merges the platform's public domains INTO your existing `map` in place** — - editing the host file (e.g. `nginx.conf`) where the splitter lives. - -The merge is surgical and idempotent: your hand-written entries are left exactly -as they are, and Edge's additions live inside a marked sub-block placed just -before the `default` line. A domain already present anywhere in the map (yours or -ours) is never re-added; re-runs never duplicate. - -```nginx - map $ssl_preread_server_name $backend_name { - migratetravel.com nginx_backend; # your entries — untouched - www.migratetravel.com nginx_backend; - # >>> HKM Edge (managed domains) >>> - app.showmeuganda.com nginx_backend; # added by `edge:apply` - admin.hkmvote.com nginx_backend; - # <<< HKM Edge (managed domains) <<< - default apache_ssl; - } -``` - -The upstream name the domains map to is `nginx_backend` by default (override with -`EDGE_STREAM_BACKEND` to match your `upstream { … }`). Writing `nginx.conf` -usually needs **`sudo`** — a failed write fails the whole apply loudly rather than -reporting success. Disable this reuse/merge behaviour and always write Edge's own -stream block with `EDGE_REUSE_STREAM=0`. Preview the exact map diff without -touching anything using `edge:apply --dry-run`. - -### Forcing a single server (no fallback) - -Auto-detection can be overridden to pin one server with **no fallback**: - -```bash -hkm cli -p edge:apply --nginx-only # nginx serves everything, NO Apache fallback -hkm cli -p edge:apply --apache-only # Apache serves everything, no fallback -``` - -`--nginx-only` renders the plain nginx reverse-proxy vhost (no stream layer); -`--apache-only` renders the Apache SSL VirtualHost. The same choice can be set as -a deploy default with `EDGE_FORCE_STRATEGY=nginx-only|apache-only`. `edge:status` -accepts the same two flags to preview the forced strategy without writing. - -## The SNI stream splitter (the `nginx-stream` output) - -```nginx -stream { - upstream nginx_backend { server 127.0.0.1:444; } - upstream apache_ssl { server 127.0.0.1:8443; } - - map $ssl_preread_server_name $backend_name { - app.example.com nginx_backend; - ... - default apache_ssl; - } - - server { - listen 443; - proxy_pass $backend_name; - ssl_preread on; - } -} -``` - -`ssl_preread` reads the TLS ClientHello's SNI **without decrypting**, then the -raw TLS stream is forwarded to whichever backend the `map` picked. TLS is -terminated by that backend (nginx on `:444`, Apache on `:8443`) — the stream -layer never sees plaintext, so certificates live on the backends. - -> The `stream {}` block must live at the nginx **main context** (top level of -> `nginx.conf`), **not** inside `http {}`. Include it: `include ;` - -## Commands - -By default every command scopes to the **current project** (read from -`base_path()/proj.json` — i.e. the project you run it in). Add **`--all`** to act -on every registered project in the global `projects.json`. - -```bash -hkm cli -p edge:status # probe host; show THIS project's plan -hkm cli -p edge:status --all # every registered project -hkm cli -p edge:apply # render + write config + sync /etc/hosts + reload -hkm cli -p edge:apply --dry-run # print what WOULD be written -hkm cli -p edge:apply --no-reload # write only; skip validate + reload -hkm cli -p edge:apply --no-hosts # skip the /etc/hosts sync -hkm cli -p edge:apply --all # render ALL projects into one file -sudo hkm cli -p --dev edge:hosts # sync THIS project's local domains → /etc/hosts -sudo hkm cli -p --dev edge:hosts --remove # remove the HKM-managed block -hkm cli -p edge:hosts --dry-run --force # preview outside dev mode -``` - -Notes: - -- **`--dev`** makes `hkm` use your dev kernel checkout — and is **required** for - `edge:hosts` (see the `/etc/hosts` rules below). -- **`sudo`** is needed to write `/etc/hosts` (and `/etc/nginx` in production). - -## Per-project serving (the vhost model) - -Edge is **project-aware**: it reads the global registry (`projects.json` → -name/path/domains) and renders **one vhost per project**, with: - -- **docroot = `/app/public`** (never the project root — keeps - `.env`/config/src/vendor out of the web tree), modeled on - `templates/app/{nginx,apache}.conf.example`; -- the **run-env injected** so the served project boots (FPM workers don't - inherit your shell/`hkm` env) — as `fastcgi_param` (nginx) / `SetEnv` (Apache). - Edge **passes through** the kernel-resolution env the launcher already exported - for the active context — it doesn't derive or configure it. `hkm … --dev` - carries `HKM_DEV_HOME` + the checkout's `HKM_KERNEL_HOME` / `PSP_GLOBAL_AUTOLOAD`; - a live `hkm cli` carries the installed kernel's paths. The pass-through set is - `kernel_env_keys` (default `HKM_KERNEL_HOME`, `HKM_DEV_HOME`, `HKM_USERDATA_DIR`, - `PSP_GLOBAL_AUTOLOAD`, `PSP_PROJECTS_DIR`) — only the ones actually set in the - environment are written. Plus `APP_ENV`. Set `EDGE_INJECT_KERNEL_ENV=false` to - skip all of them; -- a **serve model** per project: `fpm` (fastcgi to PHP-FPM) or `swoole` - (reverse-proxy to the project's OpenSwoole port). - -Each project may override the model + upstream + extra env in its **`proj.json`**: - -```jsonc -{ - "name": "shop", - "edge": { - "serve": "swoole", // or "fpm" - "port": 9601, // swoole upstream port - "socket": "unix:/run/php/php8.4-fpm.sock", // fpm socket (fpm model) - "env": { "APP_ENV": "production", "SHOP_FLAG": "1" } // per-project extras - } -} -``` - -Defaults come from `EDGE_SERVE_MODEL` / `EDGE_FPM_SOCKET` / -`EDGE_SWOOLE_HOST` / `EDGE_SWOOLE_BASE_PORT`. - -## Domains — public vs local - -Collected from the **current project's `proj.json`** `domains[]` (or every -registered project with `--all`), plus `EDGE_EXTRA_DOMAINS`, minus -`EDGE_EXCLUDE_DOMAINS`. Every hostname is validated against a strict charset -before it can reach a rendered config, so a malformed entry can never inject -directives. - -Domains are then **split**: - -- **Public** (real FQDN, e.g. `app.example.com`) → go into the **server config** - (nginx stream / vhost / Apache). -- **Local** (`*.local`, `*.test`, `*.localhost`, `*.example`, `*.invalid`, or a - single-label host like `myapp`) → are **dev-only**: kept OUT of the public - server config and written to **`/etc/hosts`** pointing at the loopback, so they - resolve on this machine. The managed block is delimited by markers, so the rest - of your hosts file is never touched and re-runs are idempotent: - - ``` - # >>> HKM Edge (local domains) >>> - 127.0.0.1 api.hkm.local - 127.0.0.1 hkm.local - # <<< HKM Edge (local domains) <<< - ``` - -Tune the local TLD set with `EDGE_LOCAL_TLDS`. **In dev mode (`hkm … --dev`, which -exports `HKM_DEV=1`) the local domains are served by the vhost automatically** — -they appear in BOTH the server config and `/etc/hosts` — so `hkm cli -p

--dev -edge:apply` gives you a working local nginx/Apache site with no extra flag. A -production (non `--dev`) run keeps them OUT of the server config (public domains -resolve through DNS); set `EDGE_LOCAL_IN_SERVER=true` to force local-in-server -outside dev too. - -### `/etc/hosts` rules — dev only, never duplicates - -**1. Requires dev mode.** `/etc/hosts` is a *developer-machine* concern: a live -server resolves its public domains through **DNS**. So the hosts sync only runs -when the launcher marks the invocation as dev (`hkm … --dev`, which exports -`HKM_DEV=1`). Outside dev: - -- `edge:hosts` **refuses** with a clear message (override with `--force`), -- `edge:apply` **silently skips** the hosts step — a VPS run never touches - `/etc/hosts`. - -```bash -sudo hkm cli -p myproject --dev edge:hosts # ✓ writes the block -hkm cli -p myproject edge:hosts # ✗ refuses (not dev mode) -hkm cli -p myproject edge:hosts --force # ✓ explicit override -``` - -> Writing `/etc/hosts` needs root, so use `sudo`. The launcher reads your -> `config.env` via `SUDO_USER`, so `sudo hkm … --dev` still finds `HKM_DEV_HOME`. - -**2. Existing entries win — a host is never duplicated.** Before writing, every -domain is checked against the rest of the hosts file (outside the managed block, -comments and multi-host lines handled). A hostname already mapped there is -**skipped and left untouched**; only genuinely missing ones are added: - -```text -already in /etc/hosts (left untouched): hkm.local -Would write 2 new local domain(s) to /etc/hosts: -127.0.0.1 api.hkm.local -127.0.0.1 app.hkm.local -``` - -Re-runs stay idempotent, and `--remove` drops the managed block (never your own -entries). - -## Configuration (`config/edge.php`, all env-driven) - -| Env | Default | Purpose | -|---|---|---| -| `EDGE_LISTEN_PORT` | `443` | public TLS port | -| `EDGE_NGINX_BACKEND` | `127.0.0.1:444` | nginx TLS backend (stream) | -| `EDGE_APACHE_BACKEND` | `127.0.0.1:8443` | Apache fallback backend (stream) | -| `EDGE_APP_BACKEND` | `127.0.0.1:8080` | app upstream (nginx-only / Apache) | -| `EDGE_SSL_CERT` / `EDGE_SSL_KEY` | `/etc/ssl/...` | cert used by nginx-only / Apache templates | -| `EDGE_STREAM_PATH` / `EDGE_NGINX_PATH` / `EDGE_APACHE_PATH` | `var/edge/*.conf` | where each config is written (point at `/etc/nginx/...` in prod) | -| `EDGE_FORCE_STRATEGY` | *(empty)* | pin a single server — `nginx-only` \| `apache-only` (no fallback); empty = auto-detect | -| `EDGE_REUSE_STREAM` | `true` | reuse an existing nginx `stream {}` splitter instead of writing a second one | -| `EDGE_RELOAD` | `false` | reload after write by default (also controllable per-command) | -| `EDGE_*_TEST_CMD` / `EDGE_*_RELOAD_CMD` | `nginx -t`, `nginx -s reload`, `apachectl configtest`, `apachectl graceful` | validate/reload commands per distro | -| `EDGE_EXTRA_DOMAINS` / `EDGE_EXCLUDE_DOMAINS` | — | comma-separated add/drop | -| `EDGE_LOCAL_TLDS` | `local,test,localhost,example,invalid` | TLDs treated as local (→ /etc/hosts) | -| `EDGE_MANAGE_HOSTS` | `true` | write local domains to /etc/hosts on apply | -| `EDGE_HOSTS_PATH` / `EDGE_HOSTS_IP` | `/etc/hosts` / `127.0.0.1` | hosts file + loopback target | -| `EDGE_LOCAL_IN_SERVER` | `false` | also include local domains in the server config | -| `EDGE_SERVE_MODEL` | `fpm` | default serve model (`fpm` \| `swoole`); per-project override in `proj.json` | -| `EDGE_FPM_SOCKET` | *(auto)* | pin the FPM socket/addr; empty = auto-resolve the socket matching the CLI PHP version | -| `EDGE_SWOOLE_HOST` / `EDGE_SWOOLE_BASE_PORT` | `127.0.0.1` / `9500` | Swoole upstream host + base port | -| `EDGE_INJECT_KERNEL_ENV` | `true` | inject `PSP_GLOBAL_AUTOLOAD` + `HKM_KERNEL_HOME` into each vhost | -| `EDGE_APP_ENV` | `APP_ENV` or `production` | `APP_ENV` written into each vhost | - -Defaults write to `var/edge/` so no root is needed to test; in production point -`EDGE_*_PATH` at the real nginx/Apache include dirs and run `hkm` with the -privileges needed to reload. - -## Notes - -- ON-DEMAND module; the value is the CLI. A route that needs the contract - declares `"requires": ["edge.routing"]`. -- Writes are atomic (temp file + rename), so a live `include` never sees a - half-written file. -- The service is DI-free (collaborators read `edge_config()`), so it constructs - without ports or a database. diff --git a/plugins/Edge/Support/helpers.php b/plugins/Edge/Support/helpers.php deleted file mode 100644 index 3510c41..0000000 --- a/plugins/Edge/Support/helpers.php +++ /dev/null @@ -1,66 +0,0 @@ -/config/edge.php is DEEP-MERGED over the plugin default — - * overriding one upstream keeps the rest of the shipped config, which the - * previous project-file-replaces-plugin-file lookup silently discarded. - * - * @return mixed the whole config array, or a single (dotted) key's value - */ - function edge_config(?string $key = null, mixed $default = null): mixed - { - if ($key === null) { - $all = function_exists('config') ? config('edge') : null; - - return is_array($all) && $all !== [] ? $all : edge_config_fallback(); - } - - if (function_exists('config')) { - $value = config('edge.' . $key, $sentinel = new stdClass()); - if ($value !== $sentinel) { - return $value; - } - } - - // No manifest compiled (e.g. a unit test that never ran the BootPipeline). - $config = edge_config_fallback(); - foreach (explode('.', $key) as $segment) { - if (!is_array($config) || !array_key_exists($segment, $config)) { - return $default; - } - $config = $config[$segment]; - } - - return $config; - } -} - -if (!function_exists('edge_config_fallback')) { - /** - * The plugin's shipped defaults, used only when no config manifest exists. - * - * @return array - */ - function edge_config_fallback(): array - { - static $config = null; - - if ($config === null) { - $loaded = require __DIR__ . '/../config/edge.php'; - $config = is_array($loaded) ? $loaded : []; - } - - return $config; - } -} diff --git a/plugins/Edge/USAGE.md b/plugins/Edge/USAGE.md deleted file mode 100644 index 389d5c2..0000000 --- a/plugins/Edge/USAGE.md +++ /dev/null @@ -1,362 +0,0 @@ -# Edge — Full Command Usage - -Edge generates the host's web-server front config (nginx / Apache) from your -registered project domains, adapting to what is actually running on the machine. - -**Invocation form** (every command): - -```bash -hkm cli -p [flags] -``` - -- Every command scopes to the **current project** by default (read from that - project's `proj.json`). Add **`--all`** to act on every project in the global - `projects.json`. -- Writing `/etc/nginx`, `/etc/hosts`, or `/etc/systemd` needs **`sudo`**. -- **`--dev`** makes `hkm` use your dev-kernel checkout (and is required for - `edge:hosts`); the launcher strips it before the command runs. - -The four commands: - -| Command | Purpose | Writes? | -|---|---|---| -| `edge:status` | Probe the host, preview the strategy that would be applied | No | -| `edge:apply` | Render + write the server config, then validate & reload | Yes | -| `edge:hosts` | Sync local (`.local`/`.test`) domains into `/etc/hosts` | Yes (`/etc/hosts`) | -| `edge:service` | Render the systemd/supervisor unit for an OpenSwoole project | Optional (`--write`) | - ---- - -## 1. `edge:status` — probe & preview (read-only) - -Detects nginx/Apache, PHP-FPM, and shows the strategy that **would** be applied. -Touches nothing. - -```bash -hkm cli -p edge:status -hkm cli -p edge:status --all -hkm cli -p edge:status --nginx-only -hkm cli -p edge:status --apache-only -``` - -| Flag | Effect | -|---|---| -| `--all` | Include every registered project (default: current only) | -| `--nginx-only` | Preview the nginx-only strategy (no Apache fallback) | -| `--apache-only` | Preview the apache-only strategy (no fallback) | - -Reports: nginx installed/active/stream, **nginx stream cfg** (an existing splitter -that will be reused), apache installed/active, PHP-FPM version + socket + active -pools, the chosen **strategy**, per-project sites (model → upstream → domains), -local domains, and the target file path. - ---- - -## 2. `edge:apply` — render + write + reload - -Detects the stack, renders the matching config, writes it atomically (temp + -rename), then validates (`nginx -t` / `apachectl configtest`) and reloads. - -```bash -hkm cli -p edge:apply # write + test + reload -hkm cli -p edge:apply --dry-run # print what WOULD be written; touch nothing -hkm cli -p edge:apply --no-reload # write file only; skip test + reload -hkm cli -p edge:apply --no-hosts # skip the /etc/hosts sync -hkm cli -p edge:apply --all # render ALL projects into one file -``` - -### Scope / write flags - -| Flag | Effect | -|---|---| -| `--dry-run` | Print the config that would be written; change nothing | -| `--no-reload` | Write the config file but do not validate or reload | -| `--no-hosts` | Skip writing local (`.local`/`.test`) domains to `/etc/hosts` | -| `--all` | Include every registered project | - -### Environment → cache profile - -Pick **at most one**; with none, the configured `APP_ENV`/`EDGE_APP_ENV` is used. -Anything unrecognised falls back to the DEVELOPMENT profile (never production). - -| Flag | APP_ENV | Cache profile | -|---|---|---| -| `--local` (or `--dev`) | local | DEVELOPMENT — everything no-store (always refetch) | -| `--development` / `-d` | development | DEVELOPMENT | -| `--production` | production | PRODUCTION — long-lived immutable static assets, HTML never cached | - -> The `hkm` launcher consumes `--dev` for **kernel** selection and strips it, so -> prefer `--local` when running via `hkm`. - -### TLS mode - -Default is config `tls.mode` (`ssl`). Pick one: - -| Flag | Emits | -|---|---| -| `--tls=ssl` | ONE `listen 443 ssl` server (HTTP/2, TLS pinning, HSTS). No `:80`. **Needs a certificate.** | -| `--tls=none` **/** `--no-ssl` | ONE `listen 80` server, plain HTTP, no certificate, **no HSTS**. | -| `--tls=both` | TWO servers: a `listen 80` block that serves the ACME challenge then `301`-redirects to HTTPS, **plus** the `listen 443 ssl` block. | -| `--ssl-cert=/path/fullchain.pem` | Override config `ssl.cert` | -| `--ssl-key=/path/privkey.pem` | Override config `ssl.key` | - -> Every `--tls` mode is validated by `nginx -t` in the test suite. The `both` -> redirect always passes ACME/Let's Encrypt HTTP-01 validation -> (`/.well-known/acme-challenge/`) through **before** the redirect, so a cert can -> still be issued/renewed over plain `:80`. - -### Strategy override - -Default is auto-detect. Pick **at most one** to pin a single server with **no -fallback**: - -| Flag | Effect | -|---|---| -| `--nginx-only` | nginx serves everything, **no Apache fallback** (plain reverse-proxy vhost, no stream) | -| `--apache-only` | Apache serves everything, no fallback (Apache SSL/HTTP VirtualHost) | - -### Strategy auto-detection (when neither `--nginx-only` nor `--apache-only`) - -| Detected stack | Strategy | Rendered config | -|---|---|---| -| nginx **and** Apache active, nginx has `stream` | `nginx-stream` | nginx SNI (L4) splitter: listed domains → nginx (`:444`), rest → Apache (`:8443`) | -| both active but nginx already has a `stream {}` splitter | `nginx-stream` (**reuse + merge**) | only the internal backend vhosts; the platform's domains are merged INTO the existing `map` in place | -| only nginx active (or Apache inactive, or nginx lacks `stream`) | `nginx-only` | plain nginx reverse-proxy vhost | -| only Apache active | `apache-only` | Apache SSL/HTTP VirtualHost | -| neither active | `none` | nothing — reports & stops | - ---- - -## 3. `edge:hosts` — sync local domains → `/etc/hosts` - -Writes `.local`/`.test`/… domains to the loopback in a marked, idempotent block -(the rest of the hosts file is never touched). **Dev-only**, needs `sudo`. - -```bash -sudo hkm cli -p --dev edge:hosts # add/update the managed block -sudo hkm cli -p --dev edge:hosts --remove # remove the HKM-managed block -hkm cli -p edge:hosts --dry-run # show changes; write nothing -hkm cli -p edge:hosts --force # allow outside dev mode -``` - -| Flag | Effect | -|---|---| -| `--dry-run` | Show what would change; write nothing | -| `--remove` | Remove the HKM-managed block from the hosts file | -| `--all` | Include every registered project | -| `--force` | Allow running outside dev mode (normally requires `--dev`) | - -Outside dev mode `edge:hosts` refuses (use `--force`), and `edge:apply` silently -skips the hosts step — a live server resolves public domains via DNS. Existing -entries win: a host already mapped elsewhere is skipped, never duplicated. - ---- - -## 4. `edge:service` — process-manager units (OpenSwoole) - -Renders the systemd/supervisor unit that keeps a project's OpenSwoole server -alive behind the reverse proxy. **PHP-FPM projects yield nothing** (php-fpm -supervises those workers). - -```bash -hkm cli -p edge:service # print the systemd unit(s) -hkm cli -p edge:service --supervisor # print supervisor program block(s) -hkm cli -p edge:service --all # every registered project -hkm cli -p edge:service --user=deploy # run the service as this user -sudo hkm cli -p edge:service --write # write to the default unit dir -hkm cli -p edge:service --write=/tmp/units # write into a specific dir -``` - -| Flag | Effect | -|---|---| -| `--supervisor` | Render a supervisor program block instead of a systemd unit | -| `--all` | Include every registered project | -| `--user=` | User/group the service runs as (default: `www-data`) | -| `--write[=dir]` | Write unit(s) to disk; optional target dir (default `/etc/systemd/system` or `/etc/supervisor/conf.d`) | -| `--local` / `--dev` / `--development` / `-d` / `--production` | Same APP_ENV flags as `edge:apply` | - -After writing: - -```bash -sudo systemctl daemon-reload && sudo systemctl enable --now -# or, for supervisor: -sudo supervisorctl reread && sudo supervisorctl update -``` - ---- - -## Reusing & updating an existing nginx stream splitter - -If your `nginx.conf` (or an included file) already has an SNI splitter — -`map $ssl_preread_server_name $backend_name { … }` routing SNI to nginx -(`127.0.0.1:444`) vs Apache (`127.0.0.1:8443`) — Edge **does not** write its own -`stream {}` block. It locates that file (via `nginx -T`), and on `edge:apply` -**merges the platform's public domains into your existing `map` in place**, -pointing them at `nginx_backend`. - -- Your hand-written entries are left untouched. -- Additions go inside a marked, idempotent sub-block before the `default` line. -- A domain already present anywhere in the map is skipped, never duplicated. -- Writing `nginx.conf` needs **`sudo`**; a failed write fails the whole apply. - -```bash -# Preview the exact map diff — writes nothing -sudo hkm cli -p edge:apply --dry-run - -# Merge the platform domains into the existing splitter + reload -sudo hkm cli -p edge:apply --production -``` - -Result before `default apache_ssl;`: - -```nginx - # >>> HKM Edge (managed domains) >>> - app.showmeuganda.com nginx_backend; - admin.hkmvote.com nginx_backend; - # <<< HKM Edge (managed domains) <<< -``` - -Controls: `EDGE_REUSE_STREAM=0` to disable (write Edge's own block instead); -`EDGE_STREAM_BACKEND` to change the upstream name the domains map to. - ---- - -## Serving without SSL (no certificate on the host) - -The default `ssl` mode expects a cert/key (`/etc/ssl/certs/hkm-edge.pem` …) — on -a host with no certificate, `nginx -t` fails. Serve plain HTTP instead: - -```bash -# per run -hkm cli -p edge:apply --no-ssl # = --tls=none -# or as a deploy default (.env) -EDGE_TLS_MODE=none -``` - -The SNI **stream splitter cannot apply without TLS** (it routes by reading the -TLS SNI), so on a no-SSL host force a single server: - -```bash -hkm cli -p edge:apply --no-ssl --nginx-only # or --apache-only -``` - -HSTS is emitted only for TLS modes, so `--no-ssl` never adds it. - ---- - -## Common workflows - -```bash -# Local dev — serve *.local sites via nginx/Apache + /etc/hosts -sudo hkm cli -p shop --dev edge:apply --local - -# Production VPS with TLS — write to /etc/nginx and reload (paths via EDGE_*_PATH) -hkm cli -p shop edge:apply --production - -# Production, NO SSL — plain HTTP, nginx as sole front -hkm cli -p shop edge:apply --production --no-ssl --nginx-only - -# Preview only, write nothing -hkm cli -p shop edge:status -hkm cli -p shop edge:apply --production --dry-run - -# Force a single server, no fallback -hkm cli -p shop edge:apply --production --nginx-only -hkm cli -p shop edge:apply --production --apache-only - -# Redirect all HTTP to HTTPS -hkm cli -p shop edge:apply --production --tls=both - -# Bring an OpenSwoole project under systemd -sudo hkm cli -p shop edge:service --production --write -sudo systemctl daemon-reload && sudo systemctl enable --now hkm-shop - -# Every registered project at once -hkm cli -p shop edge:apply --production --all -``` - ---- - -## Environment reference (`config/edge.php`) - -### Ports & TLS - -| Env | Default | Purpose | -|---|---|---| -| `EDGE_LISTEN_PORT` | `443` | public TLS port | -| `EDGE_HTTP_PORT` | `80` | public plain-HTTP port (used by `tls=none`/`both`) | -| `EDGE_TLS_MODE` | `ssl` | default TLS mode: `ssl` \| `none` \| `both` | -| `EDGE_SSL_CERT` / `EDGE_SSL_KEY` | `/etc/ssl/certs/hkm-edge.pem` / `…/private/hkm-edge.key` | cert/key for nginx-only & Apache templates | -| `EDGE_HSTS` / `EDGE_HSTS_MAX_AGE` / `EDGE_HSTS_SUBDOMAINS` / `EDGE_HSTS_PRELOAD` | `true` / `31536000` / `true` / `false` | HSTS for the PRODUCTION profile (TLS modes only); `preload` is opt-in | -| `EDGE_HSTS_DEV_MAX_AGE` | `300` | DEVELOPMENT profile HSTS max-age — always short, no subdomains, no preload | -| `EDGE_SSL_PROTOCOLS` / `EDGE_SSL_CIPHERS` / `EDGE_SSL_STAPLING` | `TLSv1.2 TLSv1.3` / *(modern)* / `false` | explicit TLS pinning (both profiles); keep stapling off for Cloudflare Origin CA | - -### CORS, methods & hardening - -| Env | Default | Purpose | -|---|---|---| -| `EDGE_CORS` | `off` | CORS mode: `off` \| `allowlist` \| `wildcard` (wildcard is opt-in) | -| `EDGE_CORS_ORIGINS` | — | allowlist origins (comma-separated), echoed back via a `$http_origin` map | -| `EDGE_CORS_METHODS` / `EDGE_CORS_HEADERS` / `EDGE_CORS_CREDENTIALS` | *(sane)* / *(sane)* / `false` | CORS method/header allowlists + credentials | -| `EDGE_ALLOWED_METHODS` | `GET\|HEAD\|POST\|PUT\|PATCH\|DELETE\|OPTIONS` | HTTP method guard (returns 405 otherwise); tighten to `GET\|HEAD\|POST` for form apps; empty disables | -| `EDGE_DENY_DIRS` | `vendor,node_modules,tests,.git,.github,bootstrap/cache` | directories denied (prefix-matched, before the static rule). `storage` is deliberately excluded (public/storage upload symlink); add it where the app has none. Empty disables | -| `EDGE_NGINX_DEBUG_LOG` | `false` | opt-in `error_log … debug` (needs nginx `--with-debug`; default level is `warn`) | -| `EDGE_NGINX_STATUS` | `true` | emit `/nginx-status` on DEVELOPMENT hosts (never in production) | - -### Strategy & upstreams - -| Env | Default | Purpose | -|---|---|---| -| `EDGE_FORCE_STRATEGY` | *(empty)* | pin a single server: `nginx-only` \| `apache-only` (no fallback); empty = auto-detect | -| `EDGE_REUSE_STREAM` | `true` | reuse an existing nginx `stream {}` splitter (merge domains into its map) instead of writing a second one | -| `EDGE_STREAM_BACKEND` | `nginx_backend` | upstream NAME the merged domains map to inside the existing splitter | -| `EDGE_NGINX_SSL_PORT` | *(auto)* | port the nginx-only vhost LISTENS on. Auto = 443 standalone, but the internal backend port (e.g. 444) when this host runs an SNI `stream {}` router that owns :443 — else nginx fails with "Address already in use". Auto-detected; force with `EDGE_BEHIND_SNI_ROUTER=1` or pin here | -| `EDGE_BEHIND_SNI_ROUTER` | `false` | force "behind an SNI router" topology (vhost listens on the internal port, redirect targets the public port) | -| `EDGE_PER_SITE_LOGS` | `true` | emit per-site access/error logs in every vhost (both profiles); false falls back to the global log | -| `EDGE_NGINX_BACKEND` | `127.0.0.1:444` | nginx TLS backend (stream) | -| `EDGE_APACHE_BACKEND` | `127.0.0.1:8443` | Apache fallback backend (stream) | -| `EDGE_APP_BACKEND` | `127.0.0.1:8080` | app upstream (nginx-only / Apache) | - -### Output paths & reload commands - -| Env | Default | Purpose | -|---|---|---| -| `EDGE_STREAM_PATH` / `EDGE_NGINX_PATH` / `EDGE_APACHE_PATH` | `var/edge/*.conf` | where each config is written (point at `/etc/nginx/...` in prod) | -| `EDGE_RELOAD` | `false` | reload after write by default (also per-command) | -| `EDGE_NGINX_TEST_CMD` / `EDGE_NGINX_RELOAD_CMD` | `nginx -t` / `nginx -s reload` | validate/reload nginx | -| `EDGE_APACHE_TEST_CMD` / `EDGE_APACHE_RELOAD_CMD` | `apachectl configtest` / `apachectl graceful` | validate/reload Apache | - -### Domains & hosts - -| Env | Default | Purpose | -|---|---|---| -| `EDGE_EXTRA_DOMAINS` / `EDGE_EXCLUDE_DOMAINS` | — | comma-separated add / drop | -| `EDGE_LOCAL_TLDS` | `local,test,localhost,example,invalid` | TLDs treated as local (→ `/etc/hosts`) | -| `EDGE_MANAGE_HOSTS` | `true` | write local domains to `/etc/hosts` on apply | -| `EDGE_HOSTS_PATH` / `EDGE_HOSTS_IP` | `/etc/hosts` / `127.0.0.1` | hosts file + loopback target | -| `EDGE_LOCAL_IN_SERVER` | `false` | also include local domains in the server config (dev turns this on) | - -### Serve model & kernel env - -| Env | Default | Purpose | -|---|---|---| -| `EDGE_SERVE_MODEL` | `php-fpm` | default serve model: `php-fpm` \| `openswoole` (per-project override in `proj.json`) | -| `EDGE_FPM_SOCKET` | *(auto)* | pin the FPM socket; empty = auto-resolve for the CLI PHP version | -| `EDGE_SWOOLE_HOST` / `EDGE_SWOOLE_PORT` | `127.0.0.1` / `9501` | OpenSwoole upstream host + port | -| `EDGE_INJECT_KERNEL_ENV` | `true` | inject kernel-resolution env into each vhost | -| `EDGE_APP_ENV` | `APP_ENV` or `production` | `APP_ENV` written into each vhost | - -### Compression, caching & rate limiting - -| Env | Default | Purpose | -|---|---|---| -| `EDGE_COMPRESSION` | `auto` | `auto` \| `brotli` \| `gzip` \| `off` (resolved per server's capability) | -| `EDGE_CACHE_ASSETS` / `EDGE_CACHE_ASSETS_TTL` | `true` / `31536000` | cache fingerprinted static assets (prod), TTL seconds | -| `EDGE_CACHE_HTML` | `false` | cache HTML/dynamic responses (almost always off) | -| `EDGE_HTTP_PRELUDE` | `false` | emit `log_format` + rate-limit zones + Cloudflare real-IP once at file top | -| `EDGE_RATE_LIMIT` / `EDGE_RATE_REQ_RATE` / `EDGE_RATE_REQ_BURST` | `true` / `10r/s` / `50` | per-vhost rate limiting (needs the prelude) | -| `EDGE_CLOUDFLARE_REAL_IP` / `EDGE_CLOUDFLARE_RANGES` | `true` / *(published list)* | restore the real visitor IP behind Cloudflare | -| `EDGE_DEV_VHOST` | *(follows APP_ENV)* | force dev vhost extras on/off (verbose logs, permissive CORS, `/nginx-status`) | - -> Full config with inline comments: `plugins/Edge/config/edge.php`. -> Architecture & the SNI splitter deep-dive: `plugins/Edge/README.md`. diff --git a/plugins/Edge/config/edge.php b/plugins/Edge/config/edge.php deleted file mode 100644 index a43e6b8..0000000 --- a/plugins/Edge/config/edge.php +++ /dev/null @@ -1,362 +0,0 @@ -/config/edge.php overrides this default. - * Everything is env-driven; the defaults are safe for local development (the - * generated files land under var/edge/ so no root is needed to write them — - * point EDGE_*_PATH at /etc/nginx or /etc/apache2 in production). - */ -$__edgeProjectsDir = (static function (): string { - // Edge is a HOST/control-plane tool: it must read the GLOBAL project registry - // (every project + its domains), which lives in the kernel home — NOT the - // per-project base_path. Resolution order: explicit override → PSP_PROJECTS_DIR - // → HKM_KERNEL_HOME/projects → base_path('projects'). - $explicit = (string) env('EDGE_PROJECTS_DIR', ''); - if ($explicit !== '') { - return rtrim($explicit, '/'); - } - $psp = (string) env('PSP_PROJECTS_DIR', ''); - if ($psp !== '') { - return rtrim($psp, '/'); - } - $home = (string) env('HKM_KERNEL_HOME', ''); - if ($home !== '') { - return rtrim($home, '/') . '/projects'; - } - return base_path('projects'); -})(); - -return [ - // The public TLS port the edge listens on. - 'listen' => (int) (env('EDGE_LISTEN_PORT') ?: 443), - - // The public plain-HTTP port (used by tls=none, and the redirect vhost of - // tls=both). - 'http' => (int) (env('EDGE_HTTP_PORT') ?: 80), - - // Default TLS mode for the rendered vhosts (overridable per `edge:apply` run): - // ssl — HTTPS only (:443) [default] - // none — plain HTTP only (:80, no certificate) - // both — plain :80 that 301-redirects to HTTPS (:443) - 'tls' => [ - 'mode' => (string) (env('EDGE_TLS_MODE') ?: 'ssl'), - ], - - // Browser-cache strategy for the generated nginx vhost. - // - // The DEVELOPMENT vs PRODUCTION profile is derived from APP_ENV alone - // (local/development → DEVELOPMENT, production → PRODUCTION, anything - // unknown → DEVELOPMENT) — see Domain\CacheProfile. It is deliberately NOT - // configurable here and never inferred from the kernel mode (HKM_DEV). - // - // DEVELOPMENT → HTML, index.php AND every asset are no-store, so a refresh - // always refetches the latest CSS/JS/JSON/images. - // PRODUCTION → never cache HTML/index.php, but cache the fingerprinted - // static assets long-term & immutable. - // - // These are independent knobs so the generator adapts without code changes: - // browser_assets cache versioned static assets at all (prod) - // browser_assets_ttl how long, in seconds (default 1 year) - // browser_html cache HTML/dynamic responses (almost always false) - // cloudflare add `immutable` to asset Cache-Control (CDN-friendly) - 'cache' => [ - 'browser_assets' => filter_var(env('EDGE_CACHE_ASSETS', 'true'), FILTER_VALIDATE_BOOL), - 'browser_assets_ttl' => (int) (env('EDGE_CACHE_ASSETS_TTL') ?: 31536000), - 'browser_html' => filter_var(env('EDGE_CACHE_HTML', 'false'), FILTER_VALIDATE_BOOL), - 'cloudflare' => filter_var(env('EDGE_CACHE_CLOUDFLARE', 'true'), FILTER_VALIDATE_BOOL), - ], - - // HTTP-context prerequisites emitted ONCE at the top of the generated file: - // the log_format, the rate-limit zones and the Cloudflare real-IP ranges that - // the vhost directives below depend on. - // - // OPT-IN (default off): if your nginx.conf already declares `log_format - // cf_realip` or `limit_req_zone … zone=general`, emitting them again is a - // duplicate-definition error. Turn this on only when Edge owns those. - 'http_prelude' => [ - 'enabled' => filter_var(env('EDGE_HTTP_PRELUDE', 'false'), FILTER_VALIDATE_BOOL), - - // log_format + the per-vhost access_log that uses it. - 'log_format' => (string) (env('EDGE_LOG_FORMAT') ?: 'cf_realip'), - 'log_buffer' => (string) (env('EDGE_LOG_BUFFER') ?: '32k'), - 'log_flush' => (string) (env('EDGE_LOG_FLUSH') ?: '5s'), - - // limit_req_zone / limit_conn_zone + the vhost limit_req / limit_conn. - 'rate_limit' => [ - 'enabled' => filter_var(env('EDGE_RATE_LIMIT', 'true'), FILTER_VALIDATE_BOOL), - 'req_zone' => (string) (env('EDGE_RATE_REQ_ZONE') ?: 'general'), - 'req_size' => (string) (env('EDGE_RATE_REQ_SIZE') ?: '10m'), - 'req_rate' => (string) (env('EDGE_RATE_REQ_RATE') ?: '10r/s'), - 'req_burst' => (int) (env('EDGE_RATE_REQ_BURST') ?: 50), - 'req_nodelay' => filter_var(env('EDGE_RATE_REQ_NODELAY', 'true'), FILTER_VALIDATE_BOOL), - 'conn_zone' => (string) (env('EDGE_RATE_CONN_ZONE') ?: 'perip'), - 'conn_size' => (string) (env('EDGE_RATE_CONN_SIZE') ?: '10m'), - 'conn_limit' => (int) (env('EDGE_RATE_CONN_LIMIT') ?: 100), - ], - - // Cloudflare: restore the visitor IP from CF-Connecting-IP so logs, rate - // limits and deny rules see the real client instead of a CF edge node. - // Ranges default to Cloudflare's published list (www.cloudflare.com/ips); - // override with a comma-separated EDGE_CLOUDFLARE_RANGES. - 'cloudflare' => [ - 'enabled' => filter_var(env('EDGE_CLOUDFLARE_REAL_IP', 'true'), FILTER_VALIDATE_BOOL), - 'header' => (string) (env('EDGE_CLOUDFLARE_HEADER') ?: 'CF-Connecting-IP'), - 'ranges' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_CLOUDFLARE_RANGES', ''))))), - ], - ], - - // Dev-only nginx vhost extras (verbose debug logging, permissive CORS, - // Disallow-all robots.txt, /nginx-status). NULL (the default) means "follow - // the APP_ENV cache profile" — set EDGE_DEV_VHOST=1/0 to force. Deliberately - // NOT derived from HKM_DEV: kernel selection and app environment are - // independent concerns. - 'dev_vhost' => ((string) env('EDGE_DEV_VHOST', '')) === '' - ? null - : filter_var(env('EDGE_DEV_VHOST'), FILTER_VALIDATE_BOOL), - - // Response compression preference. `auto` is resolved PER SERVER at render - // time from that server's own capability — nginx from its ngx_brotli build, - // Apache from its loaded mod_brotli — falling back to gzip; force with - // EDGE_COMPRESSION = brotli | gzip | off. Brotli mode also emits a gzip block - // as a fallback for clients without `br`. An explicit `brotli` still degrades - // to gzip on a server that lacks the module, so a bad choice never breaks the - // config test. An unrecognised value is treated as `auto`. - 'compression' => (static function (): string { - $mode = strtolower(trim((string) env('EDGE_COMPRESSION', 'auto'))); - return in_array($mode, ['auto', 'brotli', 'gzip', 'off'], true) ? $mode : 'auto'; - })(), - - // HTTP Strict Transport Security. Emitted ONLY for TLS modes (ssl / both) — - // never for plain HTTP. The DEVELOPMENT profile ALWAYS emits a short max-age - // (dev_max_age, default 300s) with no includeSubDomains and no preload, so a - // dev host is never pinned to HTTPS-for-a-year. PRODUCTION uses max_age (+ the - // flags below). `preload` is a long-lived, hard-to-reverse commitment — it is - // OPT-IN and never a silent default. max_age is in seconds (default 1 year). - 'hsts' => [ - 'enabled' => filter_var(env('EDGE_HSTS', 'true'), FILTER_VALIDATE_BOOL), - 'max_age' => (int) (env('EDGE_HSTS_MAX_AGE') ?: 31536000), - 'dev_max_age' => (int) (env('EDGE_HSTS_DEV_MAX_AGE') ?: 300), - 'include_subdomains' => filter_var(env('EDGE_HSTS_SUBDOMAINS', 'true'), FILTER_VALIDATE_BOOL), - 'preload' => filter_var(env('EDGE_HSTS_PRELOAD', 'false'), FILTER_VALIDATE_BOOL), - ], - - // Cross-Origin Resource Sharing for the generated vhosts. The wildcard is - // OPT-IN: `*` combined with `Allow-Headers: Authorization` on a host reachable - // beyond localhost lets any page a developer visits make authenticated - // cross-origin reads. Prefer an explicit origin allowlist (echoed back via a - // $http_origin map) over the wildcard. - // EDGE_CORS = off (default) | allowlist | wildcard - // EDGE_CORS_ORIGINS = https://a.com,https://b.com (allowlist mode) - 'cors' => [ - 'mode' => strtolower(trim((string) env('EDGE_CORS', 'off'))), - 'origins' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_CORS_ORIGINS', ''))))), - 'methods' => (string) (env('EDGE_CORS_METHODS') ?: 'GET, POST, PUT, DELETE, PATCH, OPTIONS'), - 'headers' => (string) (env('EDGE_CORS_HEADERS') ?: 'Content-Type, Authorization, X-Requested-With'), - 'credentials' => filter_var(env('EDGE_CORS_CREDENTIALS', 'false'), FILTER_VALIDATE_BOOL), - ], - - // Explicit TLS pinning for every generated TLS listener (both profiles). - // Relying on the nginx build defaults has historically left TLS 1.0/1.1 on. - // Keep stapling OFF for Cloudflare Origin CA certs (not publicly chained). - 'ssl_hardening' => [ - 'protocols' => (string) (env('EDGE_SSL_PROTOCOLS') ?: 'TLSv1.2 TLSv1.3'), - 'ciphers' => (string) (env('EDGE_SSL_CIPHERS') ?: ''), // empty = built-in modern default - 'stapling' => filter_var(env('EDGE_SSL_STAPLING', 'false'), FILTER_VALIDATE_BOOL), - ], - - // Directories denied (prefix-matched, before the static rule) so their files - // can never be served even with a whitelisted extension. Per-project because - // the right set is app-specific — `storage` is deliberately NOT in the default - // (a Laravel-style public/storage symlink serves intended uploads); add it via - // EDGE_DENY_DIRS where the app has no such symlink. Empty disables dir denies. - 'deny_dirs' => (static function (): array { - $raw = env('EDGE_DENY_DIRS'); - if ($raw === null || $raw === '') { - return ['vendor', 'node_modules', 'tests', '.git', '.github', 'bootstrap/cache']; - } - return array_values(array_filter(array_map('trim', explode(',', (string) $raw)))); - })(), - - // HTTP method allowlist emitted as a guard (`if ($request_method !~ …)`). - // Defaults to the full REST set because these apps use PUT/PATCH/DELETE; - // tighten to e.g. GET|HEAD|POST where an app only reads. Empty disables it. - 'allowed_methods' => (string) (env('EDGE_ALLOWED_METHODS') ?: 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS'), - - // error_log debug level is OPT-IN: it needs an nginx built --with-debug, is - // hugely verbose, and can log session ids/tokens. Default level is warn. - 'debug_log' => filter_var(env('EDGE_NGINX_DEBUG_LOG', 'false'), FILTER_VALIDATE_BOOL), - - // Emit the /nginx-status stub_status location on DEVELOPMENT hosts. Never - // emitted in production: behind the :443 SNI stream splitter every peer looks - // like 127.0.0.1, so `allow 127.0.0.1` would expose it to the whole internet. - 'nginx_status' => filter_var(env('EDGE_NGINX_STATUS', 'true'), FILTER_VALIDATE_BOOL), - - // Backends the traffic is routed to. - 'upstreams' => [ - // Where nginx terminates TLS for the platform's own domains. - 'nginx' => (string) (env('EDGE_NGINX_BACKEND') ?: '127.0.0.1:444'), - // Fallback web server (Apache) for everything not owned by the platform. - 'apache' => (string) (env('EDGE_APACHE_BACKEND') ?: '127.0.0.1:8443'), - // The application backend nginx/Apache reverse-proxy to (Swoole http or - // a plain listener). For PHP-FPM use fastcgi in your own vhost instead. - 'app' => (string) (env('EDGE_APP_BACKEND') ?: '127.0.0.1:8080'), - ], - - // TLS material used by the nginx-only and Apache-only templates. - 'ssl' => [ - 'cert' => (string) (env('EDGE_SSL_CERT') ?: '/etc/ssl/certs/hkm-edge.pem'), - 'key' => (string) (env('EDGE_SSL_KEY') ?: '/etc/ssl/private/hkm-edge.key'), - ], - - // Where each rendered config is written. Override to /etc/nginx/... in prod. - 'paths' => [ - 'stream' => (string) (env('EDGE_STREAM_PATH') ?: base_path('var/edge/hkm-edge-stream.conf')), - 'nginx' => (string) (env('EDGE_NGINX_PATH') ?: base_path('var/edge/hkm-edge-nginx.conf')), - 'apache' => (string) (env('EDGE_APACHE_PATH') ?: base_path('var/edge/hkm-edge-apache.conf')), - ], - - // Validation + reload commands (configurable per distro / init system). - 'commands' => [ - 'nginx_test' => (string) (env('EDGE_NGINX_TEST_CMD') ?: 'nginx -t'), - 'nginx_reload' => (string) (env('EDGE_NGINX_RELOAD_CMD') ?: 'nginx -s reload'), - 'apache_test' => (string) (env('EDGE_APACHE_TEST_CMD') ?: 'apachectl configtest'), - 'apache_reload' => (string) (env('EDGE_APACHE_RELOAD_CMD') ?: 'apachectl graceful'), - ], - - // Force a single-server strategy, bypassing host auto-detection. Empty (the - // default) = auto-detect. Set to `nginx-only` to serve everything through - // nginx with NO Apache fallback, or `apache-only` for Apache with no fallback. - // Overridable per run with `edge:apply --nginx-only` / `--apache-only`. - 'force_strategy' => (static function (): string { - $v = strtolower(trim((string) env('EDGE_FORCE_STRATEGY', ''))); - return in_array($v, ['nginx-only', 'nginx', 'apache-only', 'apache'], true) ? $v : ''; - })(), - - // When both nginx and Apache are running and nginx ALREADY has an SNI stream - // splitter configured (a `stream {}` block using ssl_preread), reuse it rather - // than writing a second, conflicting splitter. Edge then emits only the - // internal backend vhosts AND merges the platform's public domains INTO that - // existing `map $ssl_preread_server_name` in place (host file untouched apart - // from a marked, idempotent managed sub-block). Set false to always write - // Edge's own stream block instead. - 'reuse_stream' => filter_var(env('EDGE_REUSE_STREAM', 'true'), FILTER_VALIDATE_BOOL), - - // The TLS port the nginx-only vhost LISTENS on. Empty/0 = auto: the public - // `listen` port (443) standalone, but the internal backend port (from - // upstreams.nginx, e.g. 444) when this host also runs an SNI `stream {}` router - // that already binds :443 — otherwise nginx fails to start (Address already in - // use). Auto-detected when an existing splitter is found; force with - // EDGE_BEHIND_SNI_ROUTER=1, or pin the port with EDGE_NGINX_SSL_PORT. - 'nginx_ssl_port' => (int) (env('EDGE_NGINX_SSL_PORT') ?: 0), - 'behind_sni_router' => filter_var(env('EDGE_BEHIND_SNI_ROUTER', 'false'), FILTER_VALIDATE_BOOL), - - // Emit per-site access_log / error_log in every vhost (both profiles). They - // matter most in production, where incidents are reconstructed across many - // domains on one host. Set false to fall back to nginx's single global log. - 'per_site_logs' => filter_var(env('EDGE_PER_SITE_LOGS', 'true'), FILTER_VALIDATE_BOOL), - - // The upstream NAME the merged domains map to inside the existing splitter — - // must match the `upstream { … }` in your nginx.conf (default `nginx_backend`, - // i.e. 127.0.0.1:444). Only used when reusing an existing stream splitter. - 'stream_backend' => (string) (env('EDGE_STREAM_BACKEND') ?: 'nginx_backend'), - - // Reload the web server after writing (edge:apply). Can also be forced/ skipped - // with CLI flags. Off by default so a bare `edge:apply` never touches a live - // server unless you opt in. - 'reload' => filter_var(env('EDGE_RELOAD', 'false'), FILTER_VALIDATE_BOOL), - - // Domain sources. The registries are read automatically; extra/exclude let - // you add or drop hostnames without editing the registry. - 'projects_registry' => $__edgeProjectsDir . '/projects.json', - 'platform_registry' => $__edgeProjectsDir . '/platform.json', - 'extra_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXTRA_DOMAINS', ''))))), - 'exclude_domains' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_EXCLUDE_DOMAINS', ''))))), - - // Local (dev-only) domains. A domain whose TLD is in this list — or that has - // no dot at all — is treated as LOCAL: it is kept OUT of the public server - // config and written to /etc/hosts instead (pointing at the loopback). - 'local_tlds' => array_values(array_filter(array_map('trim', explode(',', (string) env('EDGE_LOCAL_TLDS', 'local,test,localhost,example,invalid'))))), - - // Write local domains into /etc/hosts on apply (needs privileges to edit it). - 'manage_hosts' => filter_var(env('EDGE_MANAGE_HOSTS', 'true'), FILTER_VALIDATE_BOOL), - 'hosts' => [ - 'path' => (string) (env('EDGE_HOSTS_PATH') ?: '/etc/hosts'), - 'ip' => (string) (env('EDGE_HOSTS_IP') ?: '127.0.0.1'), - ], - - // Also include local (.local/.test) domains in the generated server config. - // Dev mode (`hkm … --dev`, which exports HKM_DEV=1) turns this ON automatically - // so nginx/Apache serves your .local sites locally; set this true to force it - // outside dev too. A production (non --dev) run leaves it off — public domains - // resolve through DNS. Local domains are written to /etc/hosts regardless. - 'include_local_in_server' => filter_var(env('EDGE_LOCAL_IN_SERVER', 'false'), FILTER_VALIDATE_BOOL), - - // How each project is served. Per-project override via proj.json: - // { "edge": { "runtime": "openswoole", - // "openswoole": { "host": "127.0.0.1", "port": 9501, - // "websocket": "/ws", "health": "/health", - // "php": "/usr/bin/php8.3", - // "command": "bin/server.php", - // "workers": "auto" } } } - // `runtime` accepts php-fpm | openswoole (and the legacy fpm | swoole). - // Projects with no runtime set stay on PHP-FPM — existing configs are unchanged. - 'serve' => [ - 'model' => (string) (env('EDGE_SERVE_MODEL') ?: 'php-fpm'), // php-fpm | openswoole - // Empty = auto-resolve the FPM socket matching the CLI PHP version - // (multi-PHP hosts). Set explicitly to pin a socket/addr. - 'fpm_socket' => (string) env('EDGE_FPM_SOCKET', ''), - - // ── OpenSwoole ──────────────────────────────────────────────────────── - // Where the app's Swoole HTTP server listens — nginx reverse-proxies here. - 'swoole_host' => (string) (env('EDGE_SWOOLE_HOST') ?: '127.0.0.1'), - 'swoole_port' => (int) (env('EDGE_SWOOLE_PORT') ?: env('EDGE_SWOOLE_BASE_PORT') ?: 9501), - // Dedicated WebSocket location. Empty disables the extra block (the `/` - // proxy still carries the Upgrade headers). - 'websocket_path' => (string) (env('EDGE_SWOOLE_WS_PATH') ?: '/ws'), - // Values used by the generated systemd/supervisor unit (`edge:service`). - 'swoole_php' => (string) (env('EDGE_SWOOLE_PHP') ?: PHP_BINARY ?: '/usr/bin/php'), - // Entry script, relative to PROJECT_ROOT (absolute paths are used as-is). - // Matches what `hkm run --swoole` executes. - 'swoole_command' => (string) (env('EDGE_SWOOLE_COMMAND') ?: 'app/swoole/index.php'), - 'swoole_workers' => (string) (env('EDGE_SWOOLE_WORKERS') ?: 'auto'), - // Upstream pool for the OpenSwoole backend(s). Extra backends are added - // per project via proj.json: "openswoole": { "servers": ["127.0.0.1:9502"] } - 'swoole_balance' => (string) (env('EDGE_SWOOLE_BALANCE') ?: 'least_conn'), // '' = round-robin - 'swoole_max_fails' => (int) (env('EDGE_SWOOLE_MAX_FAILS') ?: 3), - 'swoole_fail_timeout' => (string) (env('EDGE_SWOOLE_FAIL_TIMEOUT') ?: '10s'), - 'swoole_keepalive' => (int) (env('EDGE_SWOOLE_KEEPALIVE') ?: 64), // 0 disables the pool - 'swoole_keepalive_timeout' => (string) env('EDGE_SWOOLE_KEEPALIVE_TIMEOUT', ''), // '' = omit - 'swoole_keepalive_requests' => (int) env('EDGE_SWOOLE_KEEPALIVE_REQUESTS', 0), // 0 = omit - - // Optional health endpoint proxied to the app (off by default). - 'health' => [ - 'enabled' => filter_var(env('EDGE_HEALTH_CHECK', 'false'), FILTER_VALIDATE_BOOL), - 'path' => (string) (env('EDGE_HEALTH_PATH') ?: '/health'), - ], - ], - - // Inject the kernel-resolution env into each vhost so FPM workers (which do - // NOT inherit your shell/hkm environment) boot against the correct kernel. - 'inject_kernel_env' => filter_var(env('EDGE_INJECT_KERNEL_ENV', 'true'), FILTER_VALIDATE_BOOL), - - // APP_ENV written into each vhost. - 'app_env' => (string) (env('EDGE_APP_ENV') ?: env('APP_ENV') ?: 'production'), - - // The launcher (`hkm run` / `hkm cli`) ALREADY exports the kernel-resolution - // env for the active context — dev (HKM_DEV_HOME + the checkout) vs live (the - // installed kernel). Edge simply PASSES THROUGH whichever of these are present - // in the environment; it does not derive, default, or configure them. So a - // dev run naturally carries HKM_DEV_HOME, a live run carries the installed - // paths, and PSP_PROJECTS_DIR only appears if you actually set it. - 'kernel_env_keys' => [ - 'HKM_KERNEL_HOME', - 'HKM_DEV_HOME', - 'HKM_USERDATA_DIR', - 'PSP_GLOBAL_AUTOLOAD', - 'PSP_PROJECTS_DIR', - ], -]; diff --git a/plugins/Edge/module.json b/plugins/Edge/module.json deleted file mode 100644 index fb7a8ed..0000000 --- a/plugins/Edge/module.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "name": "edge", - "version": "1.0.0", - "solves": "edge.routing", - "type": "module", - - "requires": [], - "exposes": ["Plugins\\Edge\\API\\Contracts\\EdgeServiceContract"], - - "routes": [], - "emits": [], - "listens": [], - - "config": [ - { "key": "EDGE_LISTEN_PORT", "type": "int", "required": false }, - { "key": "EDGE_NGINX_BACKEND", "type": "string", "required": false }, - { "key": "EDGE_APACHE_BACKEND", "type": "string", "required": false }, - { "key": "EDGE_APP_BACKEND", "type": "string", "required": false }, - { "key": "EDGE_SSL_CERT", "type": "string", "required": false }, - { "key": "EDGE_SSL_KEY", "type": "string", "required": false }, - { "key": "EDGE_STREAM_PATH", "type": "string", "required": false }, - { "key": "EDGE_NGINX_PATH", "type": "string", "required": false }, - { "key": "EDGE_APACHE_PATH", "type": "string", "required": false }, - { "key": "EDGE_RELOAD", "type": "bool", "required": false }, - { "key": "EDGE_EXTRA_DOMAINS", "type": "string", "required": false }, - { "key": "EDGE_EXCLUDE_DOMAINS", "type": "string", "required": false }, - { "key": "EDGE_NGINX_TEST_CMD", "type": "string", "required": false }, - { "key": "EDGE_NGINX_RELOAD_CMD", "type": "string", "required": false }, - { "key": "EDGE_APACHE_TEST_CMD", "type": "string", "required": false }, - { "key": "EDGE_APACHE_RELOAD_CMD", "type": "string", "required": false }, - { "key": "EDGE_LOCAL_TLDS", "type": "string", "required": false }, - { "key": "EDGE_MANAGE_HOSTS", "type": "bool", "required": false }, - { "key": "EDGE_HOSTS_PATH", "type": "string", "required": false }, - { "key": "EDGE_HOSTS_IP", "type": "string", "required": false }, - { "key": "EDGE_LOCAL_IN_SERVER", "type": "bool", "required": false }, - { "key": "EDGE_SERVE_MODEL", "type": "string", "required": false }, - { "key": "EDGE_FPM_SOCKET", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_HOST", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_BASE_PORT", "type": "int", "required": false }, - { "key": "EDGE_INJECT_KERNEL_ENV", "type": "bool", "required": false }, - { "key": "EDGE_APP_ENV", "type": "string", "required": false }, - { "key": "EDGE_TLS_MODE", "type": "string", "required": false }, - { "key": "EDGE_HTTP_PORT", "type": "int", "required": false }, - { "key": "EDGE_SWOOLE_PORT", "type": "int", "required": false }, - { "key": "EDGE_SWOOLE_WS_PATH", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_PHP", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_COMMAND", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_WORKERS", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_BALANCE", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_MAX_FAILS", "type": "int", "required": false }, - { "key": "EDGE_SWOOLE_FAIL_TIMEOUT", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_KEEPALIVE", "type": "int", "required": false }, - { "key": "EDGE_SWOOLE_KEEPALIVE_TIMEOUT", "type": "string", "required": false }, - { "key": "EDGE_SWOOLE_KEEPALIVE_REQUESTS", "type": "int", "required": false }, - { "key": "EDGE_HEALTH_CHECK", "type": "bool", "required": false }, - { "key": "EDGE_HEALTH_PATH", "type": "string", "required": false }, - { "key": "EDGE_COMPRESSION", "type": "string", "required": false }, - { "key": "EDGE_HSTS", "type": "bool", "required": false }, - { "key": "EDGE_HSTS_MAX_AGE", "type": "int", "required": false }, - { "key": "EDGE_HSTS_SUBDOMAINS", "type": "bool", "required": false }, - { "key": "EDGE_HSTS_PRELOAD", "type": "bool", "required": false }, - { "key": "EDGE_CACHE_ASSETS", "type": "bool", "required": false }, - { "key": "EDGE_CACHE_ASSETS_TTL", "type": "int", "required": false }, - { "key": "EDGE_CACHE_HTML", "type": "bool", "required": false }, - { "key": "EDGE_CACHE_CLOUDFLARE", "type": "bool", "required": false }, - { "key": "EDGE_DEV_VHOST", "type": "bool", "required": false }, - { "key": "EDGE_HTTP_PRELUDE", "type": "bool", "required": false }, - { "key": "EDGE_LOG_FORMAT", "type": "string", "required": false }, - { "key": "EDGE_LOG_BUFFER", "type": "string", "required": false }, - { "key": "EDGE_LOG_FLUSH", "type": "string", "required": false }, - { "key": "EDGE_RATE_LIMIT", "type": "bool", "required": false }, - { "key": "EDGE_RATE_REQ_ZONE", "type": "string", "required": false }, - { "key": "EDGE_RATE_REQ_SIZE", "type": "string", "required": false }, - { "key": "EDGE_RATE_REQ_RATE", "type": "string", "required": false }, - { "key": "EDGE_RATE_REQ_BURST", "type": "int", "required": false }, - { "key": "EDGE_RATE_REQ_NODELAY", "type": "bool", "required": false }, - { "key": "EDGE_RATE_CONN_ZONE", "type": "string", "required": false }, - { "key": "EDGE_RATE_CONN_SIZE", "type": "string", "required": false }, - { "key": "EDGE_RATE_CONN_LIMIT", "type": "int", "required": false }, - { "key": "EDGE_CLOUDFLARE_REAL_IP", "type": "bool", "required": false }, - { "key": "EDGE_CLOUDFLARE_HEADER", "type": "string", "required": false }, - { "key": "EDGE_CLOUDFLARE_RANGES", "type": "string", "required": false } - ] -} diff --git a/plugins/Feedback/API/DTOs/FeedbackPage.php b/plugins/Feedback/API/DTOs/FeedbackPage.php deleted file mode 100644 index 00ff5da..0000000 --- a/plugins/Feedback/API/DTOs/FeedbackPage.php +++ /dev/null @@ -1,40 +0,0 @@ - $items */ - public function __construct( - public array $items, - public bool $hasMore, - public int $limit, - ) {} - - /** The cursor to pass as ?after= for the next page (null on the last page). */ - public function nextCursor(): ?string - { - if (!$this->hasMore || $this->items === []) { - return null; - } - return $this->items[array_key_last($this->items)]->id()->value(); - } - - /** @return array */ - public function meta(): array - { - return [ - 'count' => count($this->items), - 'limit' => $this->limit, - 'has_more' => $this->hasMore, - 'next_cursor' => $this->nextCursor(), - ]; - } -} diff --git a/plugins/Feedback/API/DTOs/ListFeedbackQuery.php b/plugins/Feedback/API/DTOs/ListFeedbackQuery.php deleted file mode 100644 index 6df2904..0000000 --- a/plugins/Feedback/API/DTOs/ListFeedbackQuery.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * `after` is the opaque public feedback_id of the last row from the previous - * page; the repository resolves it to the internal sort key. `status` is an - * optional triage filter, validated against the closed enum. - */ -final readonly class ListFeedbackQuery -{ - public const DEFAULT_LIMIT = 25; - public const MAX_LIMIT = 100; - - public function __construct( - public int $limit, - public ?string $after, - public ?FeedbackStatus $status, - ) {} - - public static function fromRequest(Request $request): self - { - $limit = (int) $request->input('limit', self::DEFAULT_LIMIT); - $limit = max(1, min($limit, self::MAX_LIMIT)); - - $after = trim((string) $request->input('after', '')); - // Cursor must look like a UUID; otherwise ignore it (start from the top). - if ($after === '' || !preg_match('/^[0-9a-fA-F-]{36}$/', $after)) { - $after = null; - } - - // Unknown status → ignore the filter rather than 422 a read-only list. - $status = FeedbackStatus::tryFrom(trim((string) $request->input('status', ''))); - - return new self(limit: $limit, after: $after, status: $status); - } -} diff --git a/plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php b/plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php deleted file mode 100644 index ffd8713..0000000 --- a/plugins/Feedback/API/DTOs/SubmitFeedbackDTO.php +++ /dev/null @@ -1,60 +0,0 @@ -input('category')); - } catch (\DomainException $e) { - $errors['category'] = $e->getMessage(); - } - - $rating = null; - try { - $rating = FeedbackRating::fromNullable($request->input('rating')); - } catch (\DomainException $e) { - $errors['rating'] = $e->getMessage(); - } - - $message = null; - try { - $message = FeedbackMessage::fromString((string) $request->input('message', '')); - } catch (\DomainException $e) { - $errors['message'] = $e->getMessage(); - } - - if ($errors !== []) { - throw new ValidationException($errors); - } - - /** @var FeedbackMessage $message */ - return new self(category: $category, rating: $rating, message: $message); - } -} diff --git a/plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php b/plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php deleted file mode 100644 index 6af33c3..0000000 --- a/plugins/Feedback/API/IntegrationEvents/FeedbackSubmittedIntegrationEvent.php +++ /dev/null @@ -1,50 +0,0 @@ -version = '1.0'; - } - - public function name(): string - { - return 'feedback.submitted'; - } - - public function version(): string - { - return $this->version; - } - - /** @return array */ - public function payload(): array - { - return [ - 'feedbackId' => $this->feedbackId, - 'userId' => $this->userId, - 'category' => $this->category, - 'rating' => $this->rating, - 'occurredAt' => $this->occurredAt, - 'version' => $this->version, - ]; - } -} diff --git a/plugins/Feedback/Application/Ports/FeedbackStore.php b/plugins/Feedback/Application/Ports/FeedbackStore.php deleted file mode 100644 index 713f517..0000000 --- a/plugins/Feedback/Application/Ports/FeedbackStore.php +++ /dev/null @@ -1,30 +0,0 @@ -, 1: bool} [entries, hasMore] - */ - public function paginate(ListFeedbackQuery $query): array; - - /** Persist a status transition. Returns false if the row no longer exists. */ - public function updateStatus(string $feedbackId, string $status): bool; -} diff --git a/plugins/Feedback/Application/Services/FeedbackService.php b/plugins/Feedback/Application/Services/FeedbackService.php deleted file mode 100644 index 16270a6..0000000 --- a/plugins/Feedback/Application/Services/FeedbackService.php +++ /dev/null @@ -1,181 +0,0 @@ -identity->isGuest()) { - throw new SecurityException( - 'feedback.submit.unauthenticated', - layer: 'service.feedback', - ); - } - - $entry = FeedbackEntry::submit( - userId: $this->identity->userId, - category: $dto->category, - rating: $dto->rating, - message: $dto->message, - ); - - // A single tenant-scoped INSERT is atomic on its own. We deliberately do - // NOT use the kernel TransactionManager here: it is constructed against - // the CENTRAL DatabasePort, whereas this repository writes to the - // request's TENANT connection — wrapping it would open an idle central - // transaction that never covers the tenant write. - try { - $this->repository->insert($entry); - } catch (\Throwable $e) { - throw $this->wrap($e, 'feedback.submit.failed'); - } - - // Integration event AFTER the write succeeds. - $this->eventBus->dispatch(new FeedbackSubmittedIntegrationEvent( - feedbackId: $entry->id()->value(), - userId: $entry->userId(), - category: $entry->category()?->value, - rating: $entry->rating()?->value(), - occurredAt: $entry->createdAt()->format(\DateTimeInterface::RFC3339), - )); - - $this->audit->record('feedback.submitted', meta: ['feedbackId' => $entry->id()->value()]); - - return $entry; - } - - public function find(string $feedbackId): ?FeedbackEntry - { - $entry = $this->repository->find($feedbackId); - if ($entry === null) { - return null; - } - - // Self-or-admin: a user may read only their own feedback. - if (!$entry->isOwnedBy($this->identity->userId) - && !$this->identity->hasPermission(self::PERMISSION_MANAGE)) { - throw new SecurityException( - 'feedback.read.forbidden', - layer: 'service.feedback', - context: ['feedbackId' => $feedbackId], - ); - } - - return $entry; - } - - public function list(ListFeedbackQuery $query): FeedbackPage - { - $this->requireManage(); - - [$entries, $hasMore] = $this->repository->paginate($query); - - return new FeedbackPage( - items: $entries, - hasMore: $hasMore, - limit: $query->limit, - ); - } - - public function updateStatus(string $feedbackId, string $status): ?FeedbackEntry - { - $this->requireManage(); - - $entry = $this->repository->find($feedbackId); - if ($entry === null) { - return null; - } - - // Validate + apply the transition on the entity (forward-only) before - // touching the database — an illegal jump throws a 422. - try { - $entry->transitionTo(FeedbackStatus::fromString($status)); - } catch (\DomainException $e) { - throw new ValidationException(['status' => $e->getMessage()]); - } - - try { - $updated = $this->repository->updateStatus($feedbackId, $entry->status()->value); - } catch (\Throwable $e) { - throw $this->wrap($e, 'feedback.update_status.failed', ['feedbackId' => $feedbackId]); - } - - // The row vanished between read and write (concurrent delete). - if (!$updated) { - return null; - } - - $this->audit->record('feedback.status_changed', meta: [ - 'feedbackId' => $feedbackId, - 'status' => $entry->status()->value, - ]); - - return $entry; - } - - private function requireManage(): void - { - if (!$this->identity->hasPermission(self::PERMISSION_MANAGE)) { - throw new SecurityException( - 'feedback.manage.forbidden', - layer: 'service.feedback', - ); - } - } - - private function wrap(\Throwable $e, string $code, array $context = []): \Throwable - { - // Preserve typed faults so the kernel maps them to the right HTTP status. - if ($e instanceof ServiceException - || $e instanceof ValidationException - || $e instanceof SecurityException - || $e instanceof \AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\DomainException - ) { - return $e; - } - - return new ServiceException($code, layer: 'service.feedback', context: $context, previous: $e); - } -} diff --git a/plugins/Feedback/Domain/Entities/FeedbackEntry.php b/plugins/Feedback/Domain/Entities/FeedbackEntry.php deleted file mode 100644 index c81289b..0000000 --- a/plugins/Feedback/Domain/Entities/FeedbackEntry.php +++ /dev/null @@ -1,102 +0,0 @@ - */ - protected array $casts = [ - 'rating' => '?int', - 'created_at' => 'datetime', - ]; - - /** - * Submit brand-new feedback. The cross-module announcement is the - * FeedbackSubmittedIntegrationEvent dispatched by the service after the - * write; this aggregate records no in-process domain events. - */ - public static function submit( - string $userId, - ?FeedbackCategory $category, - ?FeedbackRating $rating, - FeedbackMessage $message, - ): self { - if ($userId === '' || mb_strlen($userId) > 31) { - throw new \DomainException('FeedbackEntry requires a valid user id.'); - } - - $e = (new self())->forceFill([ - 'feedback_id' => FeedbackId::generate()->value(), - 'user_id' => $userId, - 'category' => $category?->value, - 'rating' => $rating?->value(), - 'message' => $message->value(), - 'status' => FeedbackStatus::Received->value, - 'created_at' => new \DateTimeImmutable(), - ]); - $e->syncOriginal(); - - return $e; - } - - /** Advance triage state (forward-only). */ - public function transitionTo(FeedbackStatus $next): void - { - $current = $this->status(); - if ($next === $current) { - return; - } - if (!$current->canTransitionTo($next)) { - throw new \DomainException( - "Cannot move feedback from {$current->value} to {$next->value}." - ); - } - $this->setAttribute('status', $next->value); - } - - public function isOwnedBy(string $userId): bool - { - return hash_equals($this->userId(), $userId); - } - - public function id(): FeedbackId { return FeedbackId::fromString($this->getString('feedback_id')); } - public function userId(): string { return $this->getString('user_id'); } - public function category(): ?FeedbackCategory { $v = $this->getRawAttribute('category'); return $v === null ? null : FeedbackCategory::from((string) $v); } - public function rating(): ?FeedbackRating { $v = $this->getRawAttribute('rating'); return $v === null ? null : FeedbackRating::of((int) $v); } - public function message(): FeedbackMessage { return FeedbackMessage::fromString($this->getString('message')); } - public function status(): FeedbackStatus { return FeedbackStatus::from($this->getString('status')); } - public function createdAt(): \DateTimeImmutable { return $this->getDate('created_at') ?? new \DateTimeImmutable(); } - - /** @return array Camel-cased API shape (not the DB shape). */ - public function toArray(bool $onlyChanged = false): array - { - return [ - 'feedbackId' => $this->id()->value(), - 'userId' => $this->userId(), - 'category' => $this->category()?->value, - 'rating' => $this->rating()?->value(), - 'message' => $this->message()->value(), - 'status' => $this->status()->value, - 'createdAt' => $this->createdAt()->format(\DateTimeInterface::RFC3339), - ]; - } -} diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php b/plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php deleted file mode 100644 index 62490d9..0000000 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackCategory.php +++ /dev/null @@ -1,38 +0,0 @@ -value; - } -} diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php b/plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php deleted file mode 100644 index 42cc9fb..0000000 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackMessage.php +++ /dev/null @@ -1,43 +0,0 @@ -value); - if ($len < self::MIN) { - throw new \DomainException('Feedback message cannot be empty.'); - } - if ($len > self::MAX) { - throw new \DomainException('Feedback message cannot exceed ' . self::MAX . ' characters.'); - } - } - - public static function fromString(string $value): self - { - // Strip control chars except tab (\x09) and newline (\x0A). - $clean = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', trim($value)) ?? ''; - - return new self($clean); - } - - public function value(): string - { - return $this->value; - } -} diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackRating.php b/plugins/Feedback/Domain/ValueObjects/FeedbackRating.php deleted file mode 100644 index 1861f93..0000000 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackRating.php +++ /dev/null @@ -1,59 +0,0 @@ - self::MAX) { - throw new \DomainException('Rating must be between 1 and 5.'); - } - } - - public static function of(int $value): self - { - return new self($value); - } - - /** - * null/'' → no rating. Accepts an int, an integer-valued float (4.0) or a - * digit string; rejects fractional floats, arrays and non-numeric strings. - * Typed `mixed` because it receives raw request input (a JSON number may - * decode as float) — a narrow union would TypeError under strict_types - * instead of yielding a clean validation error. - */ - public static function fromNullable(mixed $value): ?self - { - if ($value === null || $value === '') { - return null; - } - if (is_int($value)) { - return new self($value); - } - if (is_float($value) && floor($value) === $value) { - return new self((int) $value); - } - if (is_string($value) && ctype_digit($value)) { - return new self((int) $value); - } - - throw new \DomainException('Rating must be a whole number 1–5.'); - } - - public function value(): int - { - return $this->value; - } -} diff --git a/plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php b/plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php deleted file mode 100644 index 264b223..0000000 --- a/plugins/Feedback/Domain/ValueObjects/FeedbackStatus.php +++ /dev/null @@ -1,39 +0,0 @@ -rank() > $this->rank(); - } - - private function rank(): int - { - return match ($this) { - self::Received => 0, - self::Acknowledged => 1, - self::Resolved => 2, - }; - } -} diff --git a/plugins/Feedback/Domain/ValueObjects/Ulid.php b/plugins/Feedback/Domain/ValueObjects/Ulid.php deleted file mode 100644 index 624197b..0000000 --- a/plugins/Feedback/Domain/ValueObjects/Ulid.php +++ /dev/null @@ -1,57 +0,0 @@ - 16 base32 indices forming the random component. */ - private static array $lastRand = []; - - public static function generate(): string - { - $alphabet = self::ALPHABET; - $time = (int) (microtime(true) * 1000); - - if ($time === self::$lastTime && self::$lastRand !== []) { - for ($i = 15; $i >= 0; $i--) { - if (self::$lastRand[$i] < 31) { - self::$lastRand[$i]++; - break; - } - self::$lastRand[$i] = 0; - } - } else { - self::$lastTime = $time; - self::$lastRand = []; - for ($i = 0; $i < 16; $i++) { - self::$lastRand[$i] = random_int(0, 31); - } - } - - $t = $time; - $ulid = ''; - for ($i = 9; $i >= 0; $i--) { - $ulid = $alphabet[$t % 32] . $ulid; - $t = intdiv($t, 32); - } - foreach (self::$lastRand as $idx) { - $ulid .= $alphabet[$idx]; - } - - return $ulid; - } -} diff --git a/plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php b/plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php deleted file mode 100644 index 1bca1fc..0000000 --- a/plugins/Feedback/Infrastructure/Http/Controllers/FeedbackController.php +++ /dev/null @@ -1,57 +0,0 @@ -resolveRequest(). - */ -final class FeedbackController extends ApiController -{ - public function __construct( - private readonly FeedbackService $feedback, - ) {} - - public function submit(): Response - { - $dto = SubmitFeedbackDTO::fromRequest($this->resolveRequest()); - return $this->created($this->feedback->submit($dto)->toArray()); - } - - public function show(string $id): Response - { - return $this->okOrNotFound( - $this->feedback->find($id)?->toArray(), - "Feedback [{$id}] not found.", - ); - } - - public function index(): Response - { - $page = $this->feedback->list(ListFeedbackQuery::fromRequest($this->resolveRequest())); - - return Response::json([ - 'data' => array_map(static fn($f) => $f->toArray(), $page->items), - 'meta' => $page->meta(), - ]); - } - - public function updateStatus(string $id): Response - { - $status = (string) $this->resolveRequest()->input('status', ''); - $entry = $this->feedback->updateStatus($id, $status); - - return $this->okOrNotFound($entry?->toArray(), "Feedback [{$id}] not found."); - } -} diff --git a/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php b/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php deleted file mode 100644 index cbc7207..0000000 --- a/plugins/Feedback/Infrastructure/Persistence/FeedbackRepository.php +++ /dev/null @@ -1,148 +0,0 @@ -db->execute( - 'INSERT INTO ' . self::TABLE . ' - (user_id, feedback_id, category, rating, message, status, created_at) - VALUES - (:user_id, :feedback_id, :category, :rating, :message, :status, :created_at)', - [ - 'user_id' => $entry->userId(), - 'feedback_id' => $entry->id()->value(), - 'category' => $entry->category()?->value, - 'rating' => $entry->rating()?->value(), - 'message' => $entry->message()->value(), - 'status' => $entry->status()->value, - 'created_at' => $entry->createdAt()->format('Y-m-d H:i:s'), - ], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to insert feedback.', - layer: 'repository.feedback', - context: ['feedbackId' => $entry->id()->value()], - previous: $e, - ); - } - } - - public function find(string $feedbackId): ?FeedbackEntry - { - try { - $row = $this->db->queryOne( - 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . ' - WHERE feedback_id = :id LIMIT 1', - ['id' => $feedbackId], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to load feedback.', - layer: 'repository.feedback', - previous: $e, - ); - } - - return $row === null ? null : self::hydrate($row); - } - - public function paginate(ListFeedbackQuery $query): array - { - // Inline LIMIT as a validated int: bound params bind as strings and - // native prepares (EMULATE_PREPARES=false) reject `LIMIT '100'`. - $limit = max(1, min(1001, $query->limit + 1)); - $params = []; - $where = []; - - if ($query->status !== null) { - $where[] = 'status = :status'; - $params['status'] = $query->status->value; - } - - if ($query->after !== null) { - // Keyset on the internal id resolved from the opaque public cursor. - $where[] = 'id < (SELECT id FROM ' . self::TABLE . ' WHERE feedback_id = :after)'; - $params['after'] = $query->after; - } - - $clause = $where === [] ? '' : ' WHERE ' . implode(' AND ', $where); - - try { - $rows = $this->db->query( - 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . $clause . ' - ORDER BY id DESC - LIMIT ' . $limit, - $params, - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to list feedback.', layer: 'repository.feedback', previous: $e); - } - - $hasMore = count($rows) > $query->limit; - if ($hasMore) { - array_pop($rows); - } - - return [array_map(static fn(array $r): FeedbackEntry => self::hydrate($r), $rows), $hasMore]; - } - - public function updateStatus(string $feedbackId, string $status): bool - { - try { - $affected = $this->db->execute( - 'UPDATE ' . self::TABLE . ' SET status = :status WHERE feedback_id = :id', - ['status' => $status, 'id' => $feedbackId], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to update feedback status.', - layer: 'repository.feedback', - context: ['feedbackId' => $feedbackId], - previous: $e, - ); - } - - return $affected > 0; - } - - /** @param array $row */ - private static function hydrate(array $row): FeedbackEntry - { - return FeedbackEntry::reconstitute($row); - } -} diff --git a/plugins/Feedback/Provider.php b/plugins/Feedback/Provider.php deleted file mode 100644 index 6251e0d..0000000 --- a/plugins/Feedback/Provider.php +++ /dev/null @@ -1,74 +0,0 @@ - */ - public function requires(): array - { - return ['database.management', 'audit.trail']; - } - - /** @return list */ - public function exposes(): array - { - // Feedback is consumed only through its own HTTP routes — no published contract. - return []; - } - - public function register(ModuleContainer $container): void - { - // Feedback rows are TENANT-scoped → repository takes the request's - // tenant-routed DatabasePort directly (NOT the central connection). - $container->bindInternal(FeedbackRepository::class, static fn(ModuleContainer $c) => - new FeedbackRepository($c->make(DatabasePort::class))); - - $container->bindInternal(FeedbackService::class, static fn(ModuleContainer $c) => - new FeedbackService( - repository: $c->make(FeedbackRepository::class), - eventBus: $c->make(EventBus::class), - identity: $c->make(Identity::class), - audit: $c->make(AuditServiceContract::class), - )); - - $container->bindInternal(FeedbackController::class, static fn(ModuleContainer $c) => - new FeedbackController($c->make(FeedbackService::class))); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // No pipeline hooks or subscriptions — routes carry the wiring. - } -} diff --git a/plugins/Feedback/README.md b/plugins/Feedback/README.md deleted file mode 100644 index 721258e..0000000 --- a/plugins/Feedback/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Feedback Plugin - -> Solves: **`feedback.management`** · Namespace: **`Plugins\Feedback\`** · Type: on-demand GDA module - -Owns the **feedback.management** domain — users submit categorised, rated -feedback; admins triage it. **Extracted from the User plugin** so each plugin -owns exactly one domain (the framework's "one module, one domain" rule). - -## Data & security - -- Feedback rows live in the request's **TENANT** database (`user_feedback` - table, shipped as a tenant-template migration). The repository is bound to the - tenant-routed `DatabasePort`. -- The submitter id is taken from the authenticated **Identity**, never the body. -- Reading one entry is self-or-admin; listing/triage requires the - `feedback:manage` permission. -- The `feedback.submitted` integration event is dispatched only **after** the - write succeeds. Security-relevant actions are audited to the shared central - `audit_log` table. - -## Routes - -| Method | Path | Action | Filters | -|---|---|---|---| -| POST | `/ajx/feedback` | `submit` | `auth, tenant, throttle:5,1` | -| GET | `/ajx/feedback` | `index` (triage) | `auth, tenant` | -| GET | `/ajx/feedback/{id}` | `show` | `auth, tenant` | -| PATCH | `/ajx/feedback/{id}` | `updateStatus` | `auth, tenant` | - -## Layout - -``` -API/DTOs, API/IntegrationEvents — SubmitFeedbackDTO, ListFeedbackQuery, FeedbackPage, FeedbackSubmittedIntegrationEvent -Application/Ports/FeedbackStore — persistence seam (DIP) -Application/Services/FeedbackService — authorization + orchestration -Domain/Entities/FeedbackEntry — aggregate; VOs: FeedbackId/Category/Rating/Status/Message -Infrastructure/Persistence — FeedbackRepository (DatabasePort only) -Infrastructure/Http/Controllers — FeedbackController (thin) -Infrastructure/Audit + Domain/Ulid — self-contained copies so the plugin has zero cross-plugin dependency -``` - -## Enabling - -Add `Plugins\Feedback\Provider::class` to the project's `withModules([...])` and -run `hkm plugins enable Feedback` to publish the `user_feedback` tenant -migration. `requires: ["database.management"]`. - -> Note: the User plugin no longer serves feedback. If you want a server-rendered -> feedback demo page, build it against the `/ajx/feedback` API in your frontend. diff --git a/plugins/Feedback/database/migrations/.gitkeep b/plugins/Feedback/database/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/Feedback/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php b/plugins/Feedback/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php deleted file mode 100644 index 73e3b9f..0000000 --- a/plugins/Feedback/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php +++ /dev/null @@ -1,55 +0,0 @@ -create('user_feedback', static function ($t) { - $t->id(); - - $t->char('user_id', 31) - ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); - - $t->char('feedback_id', 36) - ->comment('Public opaque ID (UUID) returned to the client'); - $t->string('category', 60)->nullable() - ->comment('search_browsing|messaging|payments|hosting|app_performance|feature_request|other'); - $t->unsignedTinyInteger('rating')->nullable() - ->comment('1-5 star rating'); - $t->text('message'); - $t->string('status', 20)->default('received') - ->comment('received|acknowledged|resolved'); - - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - - // Public id is globally unique + the client-facing lookup key. - $t->unique(['feedback_id'], 'uniq_feedback_id'); - // List a user's submissions; triage by status. - $t->index(['user_id'], 'idx_feedback_user'); - $t->index(['status'], 'idx_feedback_status'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('user_feedback'); - } -}; diff --git a/plugins/Feedback/module.json b/plugins/Feedback/module.json deleted file mode 100644 index ed07e32..0000000 --- a/plugins/Feedback/module.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "feedback", - "version": "1.0.0", - "solves": "feedback.management", - "type": "module", - "requires": [ - "database.management", - "audit.trail" - ], - "exposes": [], - "routes": [ - { - "method": "POST", - "path": "/ajx/feedback", - "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@submit", - "filters": [ - "auth", - "tenant", - "throttle:5,1" - ] - }, - { - "method": "GET", - "path": "/ajx/feedback", - "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@index", - "filters": [ - "auth", - "tenant" - ] - }, - { - "method": "GET", - "path": "/ajx/feedback/{id}", - "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@show", - "filters": [ - "auth", - "tenant" - ] - }, - { - "method": "PATCH", - "path": "/ajx/feedback/{id}", - "handler": "Plugins\\Feedback\\Infrastructure\\Http\\Controllers\\FeedbackController@updateStatus", - "filters": [ - "auth", - "tenant" - ] - } - ], - "emits": [ - "feedback.submitted" - ], - "listens": [], - "documentation": "The Feedback plugin \u2014 owns the feedback.management domain (extracted from the User plugin so each plugin owns one domain). Users submit categorised, rated feedback attributed to their authenticated Identity; admins triage it (feedback:manage). Rows live in the request's TENANT database (repository bound to the tenant-routed DatabasePort); the integration event feedback.submitted is dispatched only AFTER the write succeeds. Security-relevant actions are audited to the shared central audit_log table. Enabling publishes database/ (the user_feedback tenant-template migration).", - "config": [] -} diff --git a/plugins/HttpClient/Infrastructure/CurlHttpClient.php b/plugins/HttpClient/Infrastructure/CurlHttpClient.php deleted file mode 100644 index 34d8d34..0000000 --- a/plugins/HttpClient/Infrastructure/CurlHttpClient.php +++ /dev/null @@ -1,327 +0,0 @@ -timeout($this->defaultTimeout) - ->connectTimeout($this->defaultConnectTimeout) - ->retry($this->defaultRetry); - } - - public function request(string $method, string $url, array $options = []): HttpClientResponse - { - if (!\function_exists('curl_init')) { - throw new GatewayException( - 'Outbound HTTP requires the cURL extension.', - layer: 'gateway.http_client', - ); - } - - $method = strtoupper($method); - $url = $this->applyQuery($url, $options['query'] ?? []); - $headers = $this->buildHeaders($options); - $body = $this->buildBody($options, $headers); - - $timeout = (int) ($options['timeout'] ?? $this->defaultTimeout); - $connectTimeout = (int) ($options['connect_timeout'] ?? $this->defaultConnectTimeout); - $retry = max(0, (int) ($options['retry'] ?? $this->defaultRetry)); - - // Only idempotent verbs are auto-retried so a POST/PATCH is never - // silently re-executed. An explicit retry_methods override can widen it. - $retryMethods = $options['retry_methods'] ?? self::IDEMPOTENT_METHODS; - $maxRetries = \in_array($method, $retryMethods, true) ? $retry : 0; - - $attempt = 0; - $lastError = ''; - while (true) { - $result = $this->execute($method, $url, $headers, $body, $timeout, $connectTimeout); - - if ($result instanceof HttpClientResponse) { - // Retry transient upstream failures (5xx / 429); return anything else. - $transient = $result->status() >= 500 || $result->status() === 429; - if (!$transient || $attempt >= $maxRetries) { - return $result; - } - $lastError = "HTTP {$result->status()}"; - } else { - $lastError = $result; - if ($attempt >= $maxRetries) { - break; - } - } - - $attempt++; - $this->backoff($attempt); - } - - throw new GatewayException( - "Outbound HTTP request to [{$url}] failed: {$lastError}", - layer: 'gateway.http_client', - context: ['method' => $method, 'url' => $url, 'attempts' => $attempt + 1], - ); - } - - public function get(string $url, array $query = []): HttpClientResponse - { - return $this->request('GET', $url, $query === [] ? [] : ['query' => $query]); - } - - public function post(string $url, array $data = []): HttpClientResponse - { - return $this->request('POST', $url, $data === [] ? [] : ['json' => $data]); - } - - public function put(string $url, array $data = []): HttpClientResponse - { - return $this->request('PUT', $url, $data === [] ? [] : ['json' => $data]); - } - - public function patch(string $url, array $data = []): HttpClientResponse - { - return $this->request('PATCH', $url, $data === [] ? [] : ['json' => $data]); - } - - public function delete(string $url, array $data = []): HttpClientResponse - { - return $this->request('DELETE', $url, $data === [] ? [] : ['json' => $data]); - } - - // ── Internals ────────────────────────────────────────────────────────────── - - /** - * @param string[] $headers - * @return HttpClientResponse|string response on success, error message on transport failure - */ - private function execute(string $method, string $url, array $headers, ?string $body, int $timeout, int $connectTimeout): HttpClientResponse|string - { - $ch = curl_init(); - curl_setopt_array($ch, [ - CURLOPT_URL => $url, - CURLOPT_CUSTOMREQUEST => $method, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HEADER => true, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_TIMEOUT => $timeout, - CURLOPT_CONNECTTIMEOUT => $connectTimeout, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_SSL_VERIFYHOST => 2, - CURLOPT_ACCEPT_ENCODING => '', // negotiate + transparently decode gzip/deflate - CURLOPT_NOSIGNAL => true, // no SIGALRM DNS timeout in threaded/Swoole SAPIs - ]); - if ($body !== null) { - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - } - - // Abort mid-transfer if the response body blows past the ceiling (OOM guard). - curl_setopt($ch, CURLOPT_NOPROGRESS, false); - curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($ch, $dlTotal, $dlNow): int { - return ($dlTotal > $this->maxResponseBytes || $dlNow > $this->maxResponseBytes) ? 1 : 0; - }); - - $raw = curl_exec($ch); - if ($raw === false) { - $error = curl_error($ch); - $aborted = curl_errno($ch) === CURLE_ABORTED_BY_CALLBACK; - curl_close($ch); - if ($aborted) { - return "response exceeded {$this->maxResponseBytes} byte limit"; - } - return $error !== '' ? $error : 'unknown transport error'; - } - - $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); - curl_close($ch); - - $rawHeaders = substr((string) $raw, 0, $headerSize); - $responseBody = substr((string) $raw, $headerSize); - - return new HttpClientResponse($status, $responseBody, $this->parseHeaders($rawHeaders)); - } - - /** - * Linear backoff between retries. Uses OpenSwoole's coroutine-aware sleep - * when running inside a coroutine so the worker is not blocked; falls back - * to usleep() under PHP-FPM/CLI. - */ - private function backoff(int $attempt): void - { - $micros = 100_000 * $attempt; - if (\class_exists('\\OpenSwoole\\Coroutine') && \OpenSwoole\Coroutine::getCid() > 0) { - \OpenSwoole\Coroutine::usleep($micros); - return; - } - if (\class_exists('\\Swoole\\Coroutine') && \Swoole\Coroutine::getCid() > 0) { - \Swoole\Coroutine::usleep($micros); - return; - } - usleep($micros); - } - - /** - * @param array $query - */ - private function applyQuery(string $url, array $query): string - { - if ($query === []) { - return $url; - } - $separator = str_contains($url, '?') ? '&' : '?'; - return $url . $separator . http_build_query($query); - } - - /** - * @param array $options - * @return string[] cURL-formatted "Name: value" header lines - */ - private function buildHeaders(array $options): array - { - /** @var array $headers */ - $headers = $options['headers'] ?? []; - $lines = []; - foreach ($headers as $name => $value) { - // Reject header injection — a CR/LF in a name or value could smuggle - // additional request headers. - if (preg_match('/[\r\n]/', $name . $value) === 1) { - throw new GatewayException( - "Illegal CR/LF in outbound header [{$name}].", - layer: 'gateway.http_client', - ); - } - $lines[] = $name . ': ' . $value; - } - return $lines; - } - - /** - * @param array $options - * @param string[] $headers modified in place to add Content-Type - */ - private function buildBody(array $options, array &$headers): ?string - { - if (isset($options['json'])) { - $headers[] = 'Content-Type: application/json'; - try { - return json_encode( - $options['json'], - JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, - ); - } catch (\JsonException $e) { - throw new GatewayException( - 'Failed to JSON-encode outbound request body: ' . $e->getMessage(), - layer: 'gateway.http_client', - previous: $e, - ); - } - } - if (isset($options['form'])) { - $headers[] = 'Content-Type: application/x-www-form-urlencoded'; - return http_build_query($options['form']); - } - if (isset($options['multipart'])) { - return $this->buildMultipart($options['multipart'], $headers); - } - if (isset($options['body'])) { - return (string) $options['body']; - } - return null; - } - - /** - * Build a multipart/form-data body manually (so in-memory contents work - * without temp files) and set the boundary Content-Type header. - * - * @param array{fields?: array, files?: list} $multipart - * @param string[] $headers - */ - private function buildMultipart(array $multipart, array &$headers): string - { - $boundary = '----PSPBoundary' . bin2hex(random_bytes(12)); - $crlf = "\r\n"; - $body = ''; - - foreach (($multipart['fields'] ?? []) as $name => $value) { - $name = $this->sanitizeParam((string) $name); - $body .= '--' . $boundary . $crlf; - $body .= 'Content-Disposition: form-data; name="' . $name . '"' . $crlf . $crlf; - $body .= $value . $crlf; - } - - foreach (($multipart['files'] ?? []) as $file) { - $fieldName = $this->sanitizeParam($file['name']); - $filename = $this->sanitizeParam($file['filename'] ?? $file['name']); - $body .= '--' . $boundary . $crlf; - $body .= 'Content-Disposition: form-data; name="' . $fieldName . '"; filename="' . $filename . '"' . $crlf; - $body .= 'Content-Type: application/octet-stream' . $crlf . $crlf; - $body .= $file['contents'] . $crlf; - } - - $body .= '--' . $boundary . '--' . $crlf; - - $headers[] = 'Content-Type: multipart/form-data; boundary=' . $boundary; - return $body; - } - - /** - * Strip CR/LF and double-quotes from a multipart field/file name so it - * cannot break out of the Content-Disposition header (header injection). - */ - private function sanitizeParam(string $value): string - { - return str_replace(["\r", "\n", '"'], '', $value); - } - - /** - * @return array - */ - private function parseHeaders(string $raw): array - { - $headers = []; - // Use the last header block (after redirects/100-continue). - $blocks = preg_split("/\r?\n\r?\n/", trim($raw)) ?: []; - $last = end($blocks) ?: ''; - foreach (preg_split("/\r?\n/", $last) ?: [] as $line) { - $pos = strpos($line, ':'); - if ($pos !== false) { - $headers[trim(substr($line, 0, $pos))] = trim(substr($line, $pos + 1)); - } - } - return $headers; - } -} diff --git a/plugins/HttpClient/Infrastructure/PendingRequest.php b/plugins/HttpClient/Infrastructure/PendingRequest.php deleted file mode 100644 index 4214d4f..0000000 --- a/plugins/HttpClient/Infrastructure/PendingRequest.php +++ /dev/null @@ -1,211 +0,0 @@ - $headers - * @param list $files - * @param ?list $retryMethods - */ - private function __construct( - private readonly CurlHttpClient $client, - private readonly string $baseUrl = '', - private readonly array $headers = [], - private readonly string $bodyFormat = 'json', - private readonly int $timeout = 30, - private readonly int $connectTimeout = 10, - private readonly int $retry = 0, - private readonly array $files = [], - private readonly ?array $retryMethods = null, - ) {} - - public static function for(CurlHttpClient $client): self - { - return new self($client); - } - - public function baseUrl(string $url): static - { - return $this->with(['baseUrl' => rtrim($url, '/')]); - } - - /** @param array $headers */ - public function withHeaders(array $headers): static - { - // array_merge (not +) so a re-set header overrides the previous value. - return $this->with(['headers' => array_merge($this->headers, array_change_key_case($headers, CASE_LOWER))]); - } - - public function withHeader(string $name, string $value): static - { - return $this->withHeaders([$name => $value]); - } - - public function withToken(string $token, string $type = 'Bearer'): static - { - return $this->withHeader('Authorization', trim($type . ' ' . $token)); - } - - public function withBasicAuth(string $username, string $password): static - { - return $this->withHeader('Authorization', 'Basic ' . base64_encode($username . ':' . $password)); - } - - public function asJson(): static - { - return $this->with(['bodyFormat' => 'json']); - } - - public function asForm(): static - { - return $this->with(['bodyFormat' => 'form']); - } - - /** Switch to multipart/form-data — required before attach()ing files. */ - public function asMultipart(): static - { - return $this->with(['bodyFormat' => 'multipart']); - } - - /** - * Attach an in-memory file to a multipart request. Implies asMultipart(). - */ - public function attach(string $name, string $contents, ?string $filename = null): static - { - $files = $this->files; - $files[] = ['name' => $name, 'contents' => $contents, 'filename' => $filename]; - return $this->with(['bodyFormat' => 'multipart', 'files' => $files]); - } - - public function acceptJson(): static - { - return $this->withHeader('Accept', 'application/json'); - } - - public function timeout(int $seconds): static - { - return $this->with(['timeout' => max(1, $seconds)]); - } - - public function connectTimeout(int $seconds): static - { - return $this->with(['connectTimeout' => max(1, $seconds)]); - } - - public function retry(int $times): static - { - return $this->with(['retry' => max(0, $times)]); - } - - /** @param list $methods */ - public function retryMethods(array $methods): static - { - return $this->with(['retryMethods' => array_map('strtoupper', $methods)]); - } - - // ── Verbs ──────────────────────────────────────────────────────────────── - - /** @param array $query */ - public function get(string $url, array $query = []): HttpClientResponse - { - return $this->send('GET', $url, $query === [] ? [] : ['query' => $query]); - } - - /** @param array $data */ - public function post(string $url, array $data = []): HttpClientResponse - { - return $this->send('POST', $url, $this->payload($data)); - } - - /** @param array $data */ - public function put(string $url, array $data = []): HttpClientResponse - { - return $this->send('PUT', $url, $this->payload($data)); - } - - /** @param array $data */ - public function patch(string $url, array $data = []): HttpClientResponse - { - return $this->send('PATCH', $url, $this->payload($data)); - } - - /** @param array $data */ - public function delete(string $url, array $data = []): HttpClientResponse - { - return $this->send('DELETE', $url, $this->payload($data)); - } - - /** - * @param array $options - */ - public function send(string $method, string $url, array $options = []): HttpClientResponse - { - $options['headers'] = $this->headers + ($options['headers'] ?? []); - $options['timeout'] = $this->timeout; - $options['connect_timeout'] = $this->connectTimeout; - $options['retry'] = $this->retry; - if ($this->retryMethods !== null) { - $options['retry_methods'] = $this->retryMethods; - } - - return $this->client->request($method, $this->resolveUrl($url), $options); - } - - // ── Internals ────────────────────────────────────────────────────────────── - - /** - * @param array $data - * @return array - */ - private function payload(array $data): array - { - if ($this->bodyFormat === 'multipart' || $this->files !== []) { - return ['multipart' => ['fields' => $data, 'files' => $this->files]]; - } - if ($data === []) { - return []; - } - return $this->bodyFormat === 'form' ? ['form' => $data] : ['json' => $data]; - } - - private function resolveUrl(string $url): string - { - if ($this->baseUrl === '' || preg_match('#^https?://#i', $url) === 1) { - return $url; - } - return $this->baseUrl . '/' . ltrim($url, '/'); - } - - /** - * @param array $changes - */ - private function with(array $changes): self - { - return new self( - client: $this->client, - baseUrl: $changes['baseUrl'] ?? $this->baseUrl, - headers: $changes['headers'] ?? $this->headers, - bodyFormat: $changes['bodyFormat'] ?? $this->bodyFormat, - timeout: $changes['timeout'] ?? $this->timeout, - connectTimeout: $changes['connectTimeout'] ?? $this->connectTimeout, - retry: $changes['retry'] ?? $this->retry, - files: $changes['files'] ?? $this->files, - retryMethods: $changes['retryMethods'] ?? $this->retryMethods, - ); - } -} diff --git a/plugins/HttpClient/Provider.php b/plugins/HttpClient/Provider.php deleted file mode 100644 index d3e9eba..0000000 --- a/plugins/HttpClient/Provider.php +++ /dev/null @@ -1,63 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [HttpClientPort::class]; - } - - public function register(ModuleContainer $container): void - { - if ($container->has(HttpClientPort::class)) { - return; // a project already provided HttpClientPort - } - - $container->bind(HttpClientPort::class, static fn() => new CurlHttpClient( - defaultTimeout: (int) (env('HTTP_CLIENT_TIMEOUT',30)), - defaultConnectTimeout: (int) (env('HTTP_CLIENT_CONNECT_TIMEOUT', 10)), - defaultRetry: (int) (env('HTTP_CLIENT_RETRY', 0)), - maxResponseBytes: (int) (env('HTTP_CLIENT_MAX_RESPONSE_BYTES', 33_554_432)), - )); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - } -} diff --git a/plugins/HttpClient/module.json b/plugins/HttpClient/module.json deleted file mode 100644 index ba82c7e..0000000 --- a/plugins/HttpClient/module.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "http-client", - "version": "1.0.0", - "solves": "http.client", - "type": "module", - - "requires": [], - "exposes": ["AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\HttpClientPort"], - - "routes": [], - "emits": [], - "listens": [], - - "config": [ - { "key": "HTTP_CLIENT_TIMEOUT", "type": "int", "required": false, "default": 30 }, - { "key": "HTTP_CLIENT_CONNECT_TIMEOUT", "type": "int", "required": false, "default": 10 }, - { "key": "HTTP_CLIENT_RETRY", "type": "int", "required": false, "default": 0 }, - { "key": "HTTP_CLIENT_MAX_RESPONSE_BYTES", "type": "int", "required": false, "default": 33554432 } - ] -} diff --git a/plugins/I18n/Infrastructure/Http/LocaleStage.php b/plugins/I18n/Infrastructure/Http/LocaleStage.php deleted file mode 100644 index bf7dd78..0000000 --- a/plugins/I18n/Infrastructure/Http/LocaleStage.php +++ /dev/null @@ -1,60 +0,0 @@ -container(); - if ($container === null || !$container->has(Translator::class)) { - return $next($request); - } - - /** @var Translator $translator */ - $translator = $container->make(Translator::class); - - $locale = $this->negotiate($request, $translator); - if ($locale !== null) { - $translator->setLocale($locale); - } - - Lang::bind($translator); - try { - return $next($request); - } finally { - Lang::clear(); - } - } - - private function negotiate(Request $request, Translator $translator): ?string - { - $supported = array_values(array_filter(array_map( - 'trim', - explode(',', (string) (env('APP_LOCALES') ?: $translator->locale())), - ))); - - if ($supported === []) { - return null; - } - - return $request->negotiate()->language($supported, $translator->locale()); - } -} diff --git a/plugins/I18n/Provider.php b/plugins/I18n/Provider.php deleted file mode 100644 index d5b7c1c..0000000 --- a/plugins/I18n/Provider.php +++ /dev/null @@ -1,59 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [Translator::class]; - } - - public function register(ModuleContainer $container): void - { - $container->bind(Translator::class, static function () { - $dir = env('APP_LANG_PATH') ?: (__DIR__ . '/lang'); - return new Translator( - directory: $dir, - locale: env('APP_LOCALE') ?: 'en', - fallback: env('APP_FALLBACK_LOCALE') ?: 'en', - ); - }); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Negotiate the per-request locale and expose the Translator to the - // global helpers. after.load runs once the route's container exists. - $http->hook('after.load', LocaleStage::class, priority: 45); - } -} diff --git a/plugins/I18n/Support/Lang.php b/plugins/I18n/Support/Lang.php deleted file mode 100644 index f2b338a..0000000 --- a/plugins/I18n/Support/Lang.php +++ /dev/null @@ -1,38 +0,0 @@ - 'email']); - * // => "The email field is required." - * - * trans('checkout.greeting', ['name' => 'sam']); // ':Name' in the line - * // => "Welcome, Sam" - * - * trans('report.title', locale: 'fr'); // force a locale - * - * trans('missing.key'); // => "missing.key" - * - * @param array $replace Placeholder => value map. - * @param ?string $locale Override the active locale. - */ - function trans(string $key, array $replace = [], ?string $locale = null): string - { - return Lang::translator()?->get($key, $replace, $locale) ?? $key; - } -} - -if (!function_exists('__')) { - /** - * Alias of trans() — the terse form for use inside views and messages. - * - * Usage: - * __('validation.email', ['field' => 'email']); - * // => "The email field must be a valid email address." - * - * echo __('nav.home'); // => "Home" - * - * @param array $replace Placeholder => value map. - * @param ?string $locale Override the active locale. - */ - function __(string $key, array $replace = [], ?string $locale = null): string - { - return trans($key, $replace, $locale); - } -} - -if (!function_exists('trans_choice')) { - /** - * Pluralize a "group.key" message for a given count. - * - * The resolved line is split on '|' into forms. Simple form is - * "singular|plural" (count === 1 picks the first, otherwise the second). - * Explicit ranges take priority: '{0}' matches exactly zero, '[1,19]' - * matches an inclusive range, '[20,*]' matches 20-or-more. ':count' is always - * available as a replacement, alongside any you pass. - * - * Usage: - * // lang line: 'apple|apples' - * trans_choice('cart.apples', 1); // => "apple" - * trans_choice('cart.apples', 5); // => "apples" - * - * // lang line: '{0} No items|[1,*] :count item(s) in :cart' - * trans_choice('cart.items', 0, ['cart' => 'bag']); // => "No items" - * trans_choice('cart.items', 3, ['cart' => 'bag']); // => "3 item(s) in bag" - * - * @param int $count Drives which form is chosen. - * @param array $replace Extra placeholders (:count is auto). - * @param ?string $locale Override the active locale. - */ - function trans_choice(string $key, int $count, array $replace = [], ?string $locale = null): string - { - return Lang::translator()?->choice($key, $count, $replace, $locale) ?? $key; - } -} - -if (!function_exists('lang_has')) { - /** - * Check whether a translation key resolves to a string in the active (or - * given) locale. Does not consult the fallback locale — it tests the target - * locale only, which is what you want when deciding to render an optional, - * locale-specific block. - * - * Usage: - * if (lang_has('promo.banner')) { - * echo __('promo.banner'); - * } - * - * lang_has('promo.banner', 'fr'); // test the French file - * - * @param ?string $locale Override the active locale. - */ - function lang_has(string $key, ?string $locale = null): bool - { - return Lang::translator()?->has($key, $locale) ?? false; - } -} diff --git a/plugins/I18n/Translator.php b/plugins/I18n/Translator.php deleted file mode 100644 index 2b5ef69..0000000 --- a/plugins/I18n/Translator.php +++ /dev/null @@ -1,241 +0,0 @@ - 'The :field field is required.', ...]; - * - * Lookups use "group.key" dotted notation and :placeholder substitution: - * - * $t->get('validation.required', ['field' => 'email']); - * - * Missing keys fall back to the configured fallback locale, then to the key - * itself — translation never throws. - */ -final class Translator -{ - /** @var array> loaded [locale => group => data] */ - private array $loaded = []; - - public function __construct( - private readonly string $directory, - private string $locale = 'en', - private readonly string $fallback = 'en', - ) { - } - - /** - * Switch the active locale for subsequent lookups. Called once per request - * by LocaleStage after negotiating Accept-Language; a per-call $locale on - * get()/choice()/has() overrides this without mutating it. - * - * Usage: - * $translator->setLocale('fr'); - * $translator->get('validation.required', ['field' => 'e-mail']); - */ - public function setLocale(string $locale): void - { - $this->locale = $locale; - } - - /** - * The currently active locale. - * - * Usage: - * $translator->locale(); // => "en" - */ - public function locale(): string - { - return $this->locale; - } - - /** - * Translate a "group.key" message with :placeholder substitution. - * - * The first dotted segment names the lang file (group); the rest indexes - * into its returned array — 'validation.required' reads - * {dir}/{locale}/validation.php ['required']. A missing key falls back to the - * configured fallback locale, then returns the key itself (never throws, - * never an empty string). Placeholders fill three cases: 'name' fills :name, - * :Name and :NAME, and longer keys win so :min cannot corrupt :minutes. - * - * Usage: - * $t->get('validation.required', ['field' => 'email']); - * // => "The email field is required." - * - * $t->get('report.title', locale: 'fr'); // force a locale for one call - * - * $t->get('nope.missing'); // => "nope.missing" - * - * @param array $replace Placeholder => value map. - * @param ?string $locale Override the active locale. - */ - public function get(string $key, array $replace = [], ?string $locale = null): string - { - $locale ??= $this->locale; - - $value = $this->lookup($key, $locale); - if ($value === null && $locale !== $this->fallback) { - $value = $this->lookup($key, $this->fallback); - } - if (!is_string($value)) { - return $key; // unresolved — surface the key rather than empty string - } - - return $this->interpolate($value, $replace); - } - - /** - * Whether a key resolves to a string in the given (or active) locale. Tests - * that locale ONLY — it does not consult the fallback — so it answers "does - * this locale actually define this message?". - * - * Usage: - * if ($t->has('promo.banner')) { echo $t->get('promo.banner'); } - * - * $t->has('promo.banner', 'fr'); // test the French file specifically - * - * @param ?string $locale Override the active locale. - */ - public function has(string $key, ?string $locale = null): bool - { - return is_string($this->lookup($key, $locale ?? $this->locale)); - } - - /** - * Pluralize a message. The resolved line is split on '|' into forms selected - * by $count, with optional range prefixes: - * - * 'apple|apples' // count === 1 → first, else second - * '{0} none|[1,19] some|[20,*] many' // exact count / inclusive ranges - * - * ':count' is always available as a replacement, alongside any you pass. - * - * Usage: - * // lang line: 'apple|apples' - * $t->choice('cart.apples', 1); // => "apple" - * $t->choice('cart.apples', 5); // => "apples" - * - * // lang line: '{0} No items|[1,*] :count item(s) in :cart' - * $t->choice('cart.items', 0, ['cart' => 'bag']); // => "No items" - * $t->choice('cart.items', 3, ['cart' => 'bag']); // => "3 item(s) in bag" - * - * @param int $count Drives which form is chosen. - * @param array $replace Extra placeholders (:count is auto). - * @param ?string $locale Override the active locale. - */ - public function choice(string $key, int $count, array $replace = [], ?string $locale = null): string - { - $line = $this->get($key, [], $locale); - $replace = ['count' => $count] + $replace; - - return $this->interpolate($this->selectPluralForm($line, $count), $replace); - } - - private function lookup(string $key, string $locale): mixed - { - [$group, $item] = array_pad(explode('.', $key, 2), 2, null); - if ($group === null || $item === null) { - return null; - } - - $data = $this->loadGroup($locale, $group); - - $value = $data; - foreach (explode('.', $item) as $segment) { - if (is_array($value) && array_key_exists($segment, $value)) { - $value = $value[$segment]; - } else { - return null; - } - } - return $value; - } - - /** @return array */ - private function loadGroup(string $locale, string $group): array - { - if (isset($this->loaded[$locale][$group])) { - return $this->loaded[$locale][$group]; - } - - // Defend against path traversal in locale/group segments. - if (!preg_match('/^[A-Za-z0-9_\-]+$/', $locale) || !preg_match('/^[A-Za-z0-9_\-]+$/', $group)) { - return $this->loaded[$locale][$group] = []; - } - - $path = $this->directory . '/' . $locale . '/' . $group . '.php'; - $data = is_file($path) ? require $path : []; - - return $this->loaded[$locale][$group] = is_array($data) ? $data : []; - } - - /** - * Substitute :placeholder tokens. Keys are applied longest-first so a short - * name (:min) can never clobber a longer one that shares its prefix - * (:minutes). Each key also honours capitalized variants — :Field and - * :FIELD produce "Value" and "VALUE" respectively. - * - * @param array $replace - */ - private function interpolate(string $line, array $replace): string - { - if ($replace === []) { - return $line; - } - - // Longest key first — prevents ":min" corrupting ":minutes". - uksort($replace, static fn(string $a, string $b): int => strlen($b) <=> strlen($a)); - - $pairs = []; - foreach ($replace as $key => $value) { - $value = (string) $value; - $pairs[':' . $key] = $value; - $pairs[':' . ucfirst($key)] = ucfirst($value); - $pairs[':' . strtoupper($key)] = strtoupper($value); - } - - return strtr($line, $pairs); - } - - /** - * Pick the correct segment of a '|'-delimited plural string for $count. - * Supports Laravel-style range prefixes: '{0}', '[1,19]', '[20,*]'. - */ - private function selectPluralForm(string $line, int $count): string - { - $segments = explode('|', $line); - - // Explicit range/exact prefixes take priority. - foreach ($segments as $segment) { - if (preg_match('/^\s*(?:\{(\d+)\}|\[(\d+),(\d+|\*)\])\s*/', $segment, $m) === 1) { - $matches = isset($m[1]) && $m[1] !== '' - ? (int) $m[1] === $count - : (int) $m[2] <= $count && ($m[3] === '*' || $count <= (int) $m[3]); - - if ($matches) { - return trim(substr($segment, strlen($m[0]))); - } - } - } - - // Simple "singular|plural" fallback (no prefixes). - $plain = array_values(array_filter( - $segments, - static fn(string $s): bool => preg_match('/^\s*(?:\{\d+\}|\[\d+,(?:\d+|\*)\])/', $s) !== 1, - )); - - if ($plain === []) { - return $line; - } - - return $count === 1 ? $plain[0] : ($plain[1] ?? $plain[0]); - } -} diff --git a/plugins/I18n/lang/en/validation.php b/plugins/I18n/lang/en/validation.php deleted file mode 100644 index 4e5e644..0000000 --- a/plugins/I18n/lang/en/validation.php +++ /dev/null @@ -1,26 +0,0 @@ - 'The :field field is required.', - 'string' => 'The :field field must be a string.', - 'integer' => 'The :field field must be an integer.', - 'numeric' => 'The :field field must be numeric.', - 'boolean' => 'The :field field must be true or false.', - 'array' => 'The :field field must be an array.', - 'email' => 'The :field field must be a valid email address.', - 'url' => 'The :field field must be a valid URL.', - 'min' => 'The :field field must be at least :min.', - 'max' => 'The :field field must not be greater than :max.', - 'between' => 'The :field field must be between :min and :max.', - 'in' => 'The selected :field is invalid.', - 'regex' => 'The :field field format is invalid.', - 'same' => 'The :field field must match :other.', - 'different' => 'The :field field must be different from :other.', - 'confirmed' => 'The :field field confirmation does not match.', -]; diff --git a/plugins/I18n/module.json b/plugins/I18n/module.json deleted file mode 100644 index d2bfa36..0000000 --- a/plugins/I18n/module.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "i18n", - "version": "1.0.0", - "solves": "i18n.translation", - "type": "module", - - "requires": [], - "exposes": ["Plugins\\I18n\\Translator"], - - "routes": [], - "emits": [], - "listens": [], - - "config": [ - { "key": "APP_LOCALE", "type": "string", "required": false }, - { "key": "APP_FALLBACK_LOCALE", "type": "string", "required": false }, - { "key": "APP_LOCALES", "type": "string", "required": false }, - { "key": "APP_LANG_PATH", "type": "string", "required": false } - ] -} diff --git a/plugins/Logger/Infrastructure/AbstractLogger.php b/plugins/Logger/Infrastructure/AbstractLogger.php deleted file mode 100644 index 3f4e9eb..0000000 --- a/plugins/Logger/Infrastructure/AbstractLogger.php +++ /dev/null @@ -1,102 +0,0 @@ -log('emergency', $message, $context); } - public function alert(string|\Stringable $message, array $context = []): void { $this->log('alert', $message, $context); } - public function critical(string|\Stringable $message, array $context = []): void { $this->log('critical', $message, $context); } - public function error(string|\Stringable $message, array $context = []): void { $this->log('error', $message, $context); } - public function warning(string|\Stringable $message, array $context = []): void { $this->log('warning', $message, $context); } - public function notice(string|\Stringable $message, array $context = []): void { $this->log('notice', $message, $context); } - public function info(string|\Stringable $message, array $context = []): void { $this->log('info', $message, $context); } - public function debug(string|\Stringable $message, array $context = []): void { $this->log('debug', $message, $context); } - - /** - * Replace {placeholder} tokens with matching context values (PSR-3 §1.2). - * - * Only scalars and Stringables are substituted; an array or object context - * value stays as the literal token rather than printing "Array". The - * unsubstituted keys still travel in the structured context, so nothing is - * lost — it just does not get flattened into the human-readable message. - * - * @param array $context - */ - protected function interpolate(string $message, array $context): string - { - if (!str_contains($message, '{')) { - return $message; - } - - $replacements = []; - foreach ($context as $key => $value) { - if ($value === null || is_scalar($value) || $value instanceof \Stringable) { - $replacements['{' . $key . '}'] = (string) $value; - } - } - - return $replacements === [] ? $message : strtr($message, $replacements); - } - - /** - * Encode context for output. Never throws — a logger that fails takes down - * the operation it was only supposed to observe. - * - * @param array $context - */ - protected function encodeContext(array $context): string - { - if ($context === []) { - return ''; - } - - try { - return (string) json_encode( - $this->normalise($context), - JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, - ); - } catch (\Throwable) { - return '{"_context":"unencodable"}'; - } - } - - /** - * Make a context array safe to encode: exceptions become readable records, - * other objects collapse to their class name. Prevents both a JSON failure - * and accidentally serialising an entire object graph into a log line. - * - * @param array $context - * @return array - */ - private function normalise(array $context): array - { - foreach ($context as $key => $value) { - if ($value instanceof \Throwable) { - $context[$key] = [ - 'class' => $value::class, - 'message' => $value->getMessage(), - 'file' => $value->getFile() . ':' . $value->getLine(), - ]; - continue; - } - if (is_array($value)) { - $context[$key] = $this->normalise($value); - continue; - } - if (is_object($value) && !$value instanceof \Stringable && !$value instanceof \JsonSerializable) { - $context[$key] = $value::class; - } - } - - return $context; - } -} diff --git a/plugins/Logger/Infrastructure/FileLogger.php b/plugins/Logger/Infrastructure/FileLogger.php deleted file mode 100644 index cf1ac83..0000000 --- a/plugins/Logger/Infrastructure/FileLogger.php +++ /dev/null @@ -1,61 +0,0 @@ -passes($this->minimum)) { - return; - } - - $line = sprintf( - '[%s] %s: %s %s', - date(DATE_ATOM), - $parsed->value, - $this->interpolate((string) $message, $context), - $this->encodeContext($context), - ); - - $this->append(rtrim($line) . PHP_EOL); - } - - private function append(string $line): void - { - try { - $dir = dirname($this->file); - if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { - return; - } - - @file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX); - } catch (\Throwable) { - // Swallow — see the class docblock. There is nowhere left to report to. - } - } -} diff --git a/plugins/Logger/Infrastructure/NullLogger.php b/plugins/Logger/Infrastructure/NullLogger.php deleted file mode 100644 index df65427..0000000 --- a/plugins/Logger/Infrastructure/NullLogger.php +++ /dev/null @@ -1,33 +0,0 @@ - new PsrLoggerBridge($monolog) - * - * and every kernel and plugin component logs through it unchanged. - * - * The reverse direction is deliberately NOT provided. Exposing a LoggerPort as - * a PSR-3 logger would tempt code back into type-hinting Psr\Log\LoggerInterface, - * which is the coupling this port exists to remove. - */ -final class PsrLoggerBridge extends AbstractLogger -{ - public function __construct(private readonly LoggerInterface $psr) {} - - public function log(string $level, string|\Stringable $message, array $context = []): void - { - // PSR-3 loggers do their own interpolation and context handling, so pass - // both through untouched rather than pre-rendering. - try { - $this->psr->log($level, $message, $context); - } catch (\Throwable) { - // A logger must never break its caller. - } - } -} diff --git a/plugins/Logger/Infrastructure/StreamLogger.php b/plugins/Logger/Infrastructure/StreamLogger.php deleted file mode 100644 index 018446c..0000000 --- a/plugins/Logger/Infrastructure/StreamLogger.php +++ /dev/null @@ -1,64 +0,0 @@ -passes($this->minimum)) { - return; - } - - $line = sprintf( - '[%s] %s: %s %s', - date(DATE_ATOM), - $parsed->value, - $this->interpolate((string) $message, $context), - $this->encodeContext($context), - ); - - // Warning and worse to stderr, so `docker logs` error streams are useful. - $stream = $parsed->passes(LogLevel::Warning) ? $this->errStream() : $this->outStream(); - - if (is_resource($stream)) { - @fwrite($stream, rtrim($line) . PHP_EOL); - } - } - - /** @return resource|null */ - private function outStream() - { - return $this->out ??= (defined('STDOUT') ? STDOUT : @fopen('php://stdout', 'w')) ?: null; - } - - /** @return resource|null */ - private function errStream() - { - return $this->err ??= (defined('STDERR') ? STDERR : @fopen('php://stderr', 'w')) ?: null; - } -} diff --git a/plugins/Logger/Provider.php b/plugins/Logger/Provider.php deleted file mode 100644 index 38d344b..0000000 --- a/plugins/Logger/Provider.php +++ /dev/null @@ -1,95 +0,0 @@ -withPorts([...])); this Provider registers a config-driven - * fallback so LoggerPort always resolves rather than silently being absent. - * - * That fallback matters more than usual here. The failure this plugin exists to - * fix was not "logging is hard to configure" — it was that logging LOOKED - * configured and went nowhere: the single binding of Psr\Log\LoggerInterface in - * the codebase pointed at a null logger, so Database, Tenancy and EventBus all - * wrote into a black hole. A resolvable, file-backed default is what makes that - * failure mode impossible to reach by accident. - */ -final class Provider implements ModuleContract -{ - public function solves(): string - { - return 'logging.application'; - } - - /** @return list */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [LoggerPort::class]; - } - - public function register(ModuleContainer $container): void - { - if ($container->has(LoggerPort::class)) { - return; // the project wired it in withPorts() — respect that - } - - $container->bind(LoggerPort::class, static fn(): LoggerPort => self::fromConfig()); - } - - /** - * Build the adapter named by config/logger.php. - * - * Public + static so a project bootstrap can reuse the same resolution in - * withPorts() without duplicating the channel switch. - */ - public static function fromConfig(): LoggerPort - { - $config = \function_exists('config') ? config('logger', []) : []; - $channel = \is_array($config) ? (string) ($config['channel'] ?? 'file') : 'file'; - $level = LogLevel::parse(\is_array($config) ? (string) ($config['level'] ?? 'debug') : 'debug'); - - return match ($channel) { - 'null' => new NullLogger(), - 'stream' => new StreamLogger(minimum: $level), - default => new FileLogger(self::file($config), $level), - }; - } - - /** @param array|mixed $config */ - private static function file(mixed $config): string - { - $configured = \is_array($config) ? (string) ($config['file'] ?? '') : ''; - - // Separate from errors.log on purpose — that file belongs to the - // ErrorPipeline/ErrorGuard and should stay exception-only. - return $configured !== '' ? $configured : Paths::logs('app.log'); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - } -} diff --git a/plugins/Logger/config/logger.php b/plugins/Logger/config/logger.php deleted file mode 100644 index e28071e..0000000 --- a/plugins/Logger/config/logger.php +++ /dev/null @@ -1,38 +0,0 @@ -/config/logger.php over this file. - */ -return [ - /* - * Where records go. - * - * file append to LOG_FILE (default var/logs/app.log) - * stream stdout/stderr — the right choice in containers - * null discard. Only ever set this deliberately; see NullLogger's - * docblock for why a silently-null logger is a real hazard. - */ - 'channel' => env('LOG_CHANNEL', 'file'), - - /* - * Minimum severity to record. Anything less severe is dropped. - * emergency|alert|critical|error|warning|notice|info|debug - * - * 'debug' in development, 'info' or 'warning' in production. - */ - 'level' => env('LOG_LEVEL', 'debug'), - - /* - * File channel target. Defaults to the project's var/logs/app.log. - * - * Deliberately NOT errors.log: that file belongs to the ErrorPipeline and - * ErrorGuard, which record escaped Throwables. Mixing routine application - * logging into it would bury the exceptions it exists to surface. - */ - 'file' => env('LOG_FILE', ''), -]; diff --git a/plugins/Logger/module.json b/plugins/Logger/module.json deleted file mode 100644 index a6fbf2c..0000000 --- a/plugins/Logger/module.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "logger", - "version": "1.0.0", - "solves": "logging.application", - "type": "module", - - "requires": [], - "exposes": ["AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\LoggerPort"], - - "config": [ - { "key": "LOG_CHANNEL", "type": "string", "required": false }, - { "key": "LOG_LEVEL", "type": "string", "required": false }, - { "key": "LOG_FILE", "type": "string", "required": false } - ] -} diff --git a/plugins/Mail/API/Contracts/MailerContract.php b/plugins/Mail/API/Contracts/MailerContract.php deleted file mode 100644 index 7a06f67..0000000 --- a/plugins/Mail/API/Contracts/MailerContract.php +++ /dev/null @@ -1,31 +0,0 @@ -data(); - $mime = (string) ($data['mime'] ?? ''); - $from = (string) ($data['from'] ?? ''); - /** @var list $recipients */ - $recipients = array_values((array) ($data['recipients'] ?? [])); - - if ($mime === '' || $from === '' || $recipients === []) { - return JobResult::skipped('Malformed mail payload.'); - } - - $this->transport->send($from, $recipients, $mime); - - return JobResult::success(['recipients' => count($recipients)]); - } - - public function failed(JobPayload $payload, \Throwable $e): void - { - error_log('[mail] permanent delivery failure: ' . $e->getMessage()); - } -} diff --git a/plugins/Mail/Application/Mailer.php b/plugins/Mail/Application/Mailer.php deleted file mode 100644 index 9f4a69a..0000000 --- a/plugins/Mail/Application/Mailer.php +++ /dev/null @@ -1,169 +0,0 @@ -charset($this->charset); - if ($this->fromEmail !== '') { - $m->from($this->fromEmail, $this->fromName); - } - return $m; - } - - public function dispatch(Message $message): void - { - $compiled = $this->compile($message); - $this->transport->send($compiled['from'], $compiled['recipients'], $compiled['mime']); - } - - public function enqueue(Message $message): string - { - $compiled = $this->compile($message); - - if ($this->queue === null) { - $this->transport->send($compiled['from'], $compiled['recipients'], $compiled['mime']); - return ''; - } - - return $this->queue->push(self::QUEUE_JOB, $compiled, $this->queueName); - } - - /** Compile the full MIME (headers + body, DKIM-signed if configured) without sending. */ - public function preview(Message $message): string - { - return $this->compile($message)['mime']; - } - - // ── MailPort (kernel, view-based) ──────────────────────────────────────── - - /** @param string|array $to */ - public function send(string|array $to, string $subject, string $view, array $data = []): void - { - $this->dispatch($this->fromView($to, $subject, $view, $data)); - } - - /** @param string|array $to */ - public function queue(string|array $to, string $subject, string $view, array $data = []): string - { - return $this->enqueue($this->fromView($to, $subject, $view, $data)); - } - - // ── internals ──────────────────────────────────────────────────────────── - - /** @return array{from: string, recipients: list, mime: string} */ - private function compile(Message $message): array - { - if ($message->getFrom() === null) { - if ($this->fromEmail === '') { - throw new MailException('No From address on the message and no default configured.'); - } - $message->from($this->fromEmail, $this->fromName); - } - - $built = $this->mime->build($message); - $headers = $built['headers']; - $body = $built['body']; - - if ($this->dkim !== null) { - array_unshift($headers, $this->dkim->sign($headers, $body)); - } - - /** @var \Plugins\Mail\Domain\Address $from */ - $from = $message->getFrom(); - $envelope = $message->getReturnPath() ?? $message->getSender()?->email ?? $from->email; - - return [ - 'from' => $envelope, - 'recipients' => $message->recipientEmails(), - 'mime' => implode("\r\n", $headers) . "\r\n\r\n" . $body, - ]; - } - - /** @param string|array $to */ - private function fromView(string|array $to, string $subject, string $view, array $data): Message - { - $message = $this->message()->subject($subject)->html($this->render($view, $data)); - - foreach ($this->normaliseRecipients($to) as $email => $name) { - $message->to($email, $name); - } - - return $message; - } - - private function render(string $view, array $data): string - { - // With the View plugin, treat $view as a template name; without it, the - // caller passed raw HTML (so MailPort works even with no renderer bound). - if ($this->views === null) { - return $view; - } - - // render()'s second argument is render OPTIONS (layout/cache), NOT view - // data — template variables must go through setData(), otherwise nothing - // is extracted and every placeholder renders empty. 'raw' because mail - // templates escape what they print themselves. - return $this->views->setData($data, 'raw')->render($view); - } - - /** - * @param string|array $to - * @return array email => name - */ - private function normaliseRecipients(string|array $to): array - { - if (is_string($to)) { - return [$to => '']; - } - - $out = []; - foreach ($to as $key => $value) { - if (is_int($key)) { - $out[$value] = ''; // list of emails - } else { - $out[$key] = $value; // email => name - } - } - return $out; - } -} diff --git a/plugins/Mail/Domain/Address.php b/plugins/Mail/Domain/Address.php deleted file mode 100644 index 50f828f..0000000 --- a/plugins/Mail/Domain/Address.php +++ /dev/null @@ -1,54 +0,0 @@ -email = $email; - $this->name = trim($name); - } - - /** RFC 5322 header form: `"Name" ` (name MIME-encoded when non-ASCII). */ - public function toHeader(string $charset = 'UTF-8'): string - { - if ($this->name === '') { - return $this->email; - } - - $name = preg_match('/[^\x20-\x7E]/', $this->name) === 1 - ? mb_encode_mimeheader($this->name, $charset, 'B', "\r\n") // chunked encoded-words - : '"' . addcslashes($this->name, '"\\') . '"'; - - return $name . ' <' . $this->email . '>'; - } - - private static function hasControlChars(string $value): bool - { - return preg_match('/[\r\n\t\x00]/', $value) === 1; - } -} diff --git a/plugins/Mail/Domain/Attachment.php b/plugins/Mail/Domain/Attachment.php deleted file mode 100644 index 14f13a6..0000000 --- a/plugins/Mail/Domain/Attachment.php +++ /dev/null @@ -1,85 +0,0 @@ -`. - */ -final readonly class Attachment -{ - private function __construct( - public string $name, // filename shown to the recipient - public string $mimeType, - public bool $inline, - public string $cid, // Content-ID (inline only) - public ?string $path, // read at build time when set - public ?string $data, // raw bytes when path is null - ) { - if (preg_match('/[\r\n\x00]/', $name) === 1 || preg_match('/[\r\n\x00]/', $cid) === 1) { - throw new MailException('Attachment name/cid may not contain control characters.'); - } - } - - public static function fromPath(string $path, string $name = '', string $mimeType = ''): self - { - return new self( - name: $name !== '' ? $name : basename($path), - mimeType: $mimeType !== '' ? $mimeType : self::guessMime($path), - inline: false, - cid: '', - path: $path, - data: null, - ); - } - - public static function fromData(string $data, string $name, string $mimeType = 'application/octet-stream'): self - { - return new self($name, $mimeType, false, '', null, $data); - } - - /** Inline image referenced from HTML via cid:. */ - public static function inline(string $pathOrData, string $cid, string $name = '', string $mimeType = '', bool $isPath = true): self - { - return new self( - name: $name !== '' ? $name : ($isPath ? basename($pathOrData) : $cid), - mimeType: $mimeType !== '' ? $mimeType : ($isPath ? self::guessMime($pathOrData) : 'application/octet-stream'), - inline: true, - cid: $cid, - path: $isPath ? $pathOrData : null, - data: $isPath ? null : $pathOrData, - ); - } - - /** Resolve the raw bytes (reads the file when path-backed). */ - public function contents(): string - { - if ($this->data !== null) { - return $this->data; - } - if ($this->path === null || is_file($this->path) === false || is_readable($this->path) === false) { - throw new MailException("Attachment not readable: {$this->path}"); - } - $bytes = file_get_contents($this->path); - if ($bytes === false) { - throw new MailException("Failed to read attachment: {$this->path}"); - } - return $bytes; - } - - private static function guessMime(string $path): string - { - if (function_exists('mime_content_type') && is_file($path)) { - $type = @mime_content_type($path); - if (is_string($type) && $type !== '') { - return $type; - } - } - return 'application/octet-stream'; - } -} diff --git a/plugins/Mail/Domain/MailException.php b/plugins/Mail/Domain/MailException.php deleted file mode 100644 index 0bd7de5..0000000 --- a/plugins/Mail/Domain/MailException.php +++ /dev/null @@ -1,10 +0,0 @@ - */ - private array $to = []; - /** @var list

*/ - private array $cc = []; - /** @var list
*/ - private array $bcc = []; - /** @var list
*/ - private array $replyTo = []; - - private string $subject = ''; - private string $html = ''; - private string $text = ''; - private string $charset = 'UTF-8'; - private Priority $priority = Priority::Normal; - private ?Address $confirmReadingTo = null; // Disposition-Notification-To - - /** @var list */ - private array $attachments = []; - /** @var array */ - private array $headers = []; - /** @var array */ - private array $metadata = []; - - public static function make(): self - { - return new self(); - } - - // ── envelope / from ────────────────────────────────────────────────────── - - public function from(string $email, string $name = ''): self - { - $this->from = new Address($email, $name); - return $this; - } - - /** Distinct envelope sender (Sender header + default Return-Path). */ - public function sender(string $email, string $name = ''): self - { - $this->sender = new Address($email, $name); - return $this; - } - - public function returnPath(string $email): self - { - $this->returnPath = (new Address($email))->email; - return $this; - } - - // ── recipients ─────────────────────────────────────────────────────────── - - public function to(string $email, string $name = ''): self - { - $this->to[] = new Address($email, $name); - return $this; - } - - public function cc(string $email, string $name = ''): self - { - $this->cc[] = new Address($email, $name); - return $this; - } - - public function bcc(string $email, string $name = ''): self - { - $this->bcc[] = new Address($email, $name); - return $this; - } - - public function replyTo(string $email, string $name = ''): self - { - $this->replyTo[] = new Address($email, $name); - return $this; - } - - // ── content ────────────────────────────────────────────────────────────── - - public function subject(string $subject): self - { - // Strip control chars — the subject becomes a header. - $this->subject = (string) preg_replace('/[\r\n\x00]/', '', $subject); - return $this; - } - - public function html(string $html): self - { - $this->html = $html; - return $this; - } - - public function text(string $text): self - { - $this->text = $text; - return $this; - } - - public function charset(string $charset): self - { - $this->charset = $charset; - return $this; - } - - public function priority(Priority $priority): self - { - $this->priority = $priority; - return $this; - } - - /** Request a read receipt to this address (Disposition-Notification-To). */ - public function confirmReadingTo(string $email, string $name = ''): self - { - $this->confirmReadingTo = new Address($email, $name); - return $this; - } - - // ── attachments ────────────────────────────────────────────────────────── - - public function attach(string $path, string $name = '', string $mimeType = ''): self - { - $this->attachments[] = Attachment::fromPath($path, $name, $mimeType); - return $this; - } - - public function attachData(string $data, string $name, string $mimeType = 'application/octet-stream'): self - { - $this->attachments[] = Attachment::fromData($data, $name, $mimeType); - return $this; - } - - /** Embed an image and reference it in HTML as ``. */ - public function embed(string $path, string $cid, string $name = '', string $mimeType = ''): self - { - $this->attachments[] = Attachment::inline($path, $cid, $name, $mimeType, isPath: true); - return $this; - } - - public function embedData(string $data, string $cid, string $name = '', string $mimeType = 'application/octet-stream'): self - { - $this->attachments[] = Attachment::inline($data, $cid, $name, $mimeType, isPath: false); - return $this; - } - - // ── headers / metadata ─────────────────────────────────────────────────── - - public function header(string $name, string $value): self - { - if (preg_match('/[\r\n\x00]/', $name . $value) === 1) { - throw new MailException('Custom headers may not contain control characters.'); - } - $this->headers[$name] = $value; - return $this; - } - - /** Arbitrary tag for logging / webhooks (not sent unless you also add a header). */ - public function tag(string $key, string|int|float|bool $value): self - { - $this->metadata[$key] = $value; - return $this; - } - - // ── accessors (used by the MIME builder / transports) ──────────────────── - - public function getFrom(): ?Address { return $this->from; } - public function getSender(): ?Address { return $this->sender; } - public function getReturnPath(): ?string { return $this->returnPath; } - /** @return list
*/ public function getTo(): array { return $this->to; } - /** @return list
*/ public function getCc(): array { return $this->cc; } - /** @return list
*/ public function getBcc(): array { return $this->bcc; } - /** @return list
*/ public function getReplyTo(): array { return $this->replyTo; } - public function getSubject(): string { return $this->subject; } - public function getHtml(): string { return $this->html; } - public function getText(): string { return $this->text; } - public function getCharset(): string { return $this->charset; } - public function getPriority(): Priority { return $this->priority; } - public function getConfirmReadingTo(): ?Address { return $this->confirmReadingTo; } - /** @return list */ public function getAttachments(): array { return $this->attachments; } - /** @return array */ public function getHeaders(): array { return $this->headers; } - /** @return array */ public function getMetadata(): array { return $this->metadata; } - - /** All RCPT recipients (to + cc + bcc) as bare addresses. @return list */ - public function recipientEmails(): array - { - $all = []; - foreach ([...$this->to, ...$this->cc, ...$this->bcc] as $a) { - $all[$a->email] = true; // dedupe - } - return array_keys($all); - } -} diff --git a/plugins/Mail/Domain/Priority.php b/plugins/Mail/Domain/Priority.php deleted file mode 100644 index 9fc70cc..0000000 --- a/plugins/Mail/Domain/Priority.php +++ /dev/null @@ -1,22 +0,0 @@ - 'High', - self::Normal => 'Normal', - self::Low => 'Low', - }; - } -} diff --git a/plugins/Mail/Infrastructure/Http/MailDemoController.php b/plugins/Mail/Infrastructure/Http/MailDemoController.php deleted file mode 100644 index 06fda9a..0000000 --- a/plugins/Mail/Infrastructure/Http/MailDemoController.php +++ /dev/null @@ -1,225 +0,0 @@ -request`, never a `Request` parameter. - */ -final class MailDemoController extends ApiController -{ - public function __construct( - private readonly MailerContract $mailer, // rich API: message/dispatch/enqueue/preview - private readonly MailPort $mail, // kernel view-based shortcut: send/queue - ) { - } - - /** GET /mail/demo — overview + endpoint map (never sends). */ - public function index(): Response - { - return $this->ok([ - 'plugin' => 'mail', - 'transport' => $this->transport(), - 'from' => env('MAIL_FROM_ADDRESS', '(unset — falls back to demo@example.com)'), - 'sending_enabled' => $this->guard() === null, - 'endpoints' => [ - 'GET /mail/demo' => 'This overview.', - 'GET /mail/demo/preview' => 'Build a rich sample message and return its raw MIME. Never sends.', - 'GET /mail/demo/send' => 'MailerContract::dispatch() the sample message. ?to=you@example.com', - 'GET /mail/demo/queue' => 'MailerContract::enqueue() for background delivery. ?to=you@example.com', - 'GET /mail/demo/view' => 'Kernel MailPort::send() view-based shortcut. ?to=you@example.com', - ], - 'tips' => [ - 'Test without a real SMTP server: set MAIL_TRANSPORT=array or MAIL_TRANSPORT=log.', - 'send/queue/view return 403 unless a non-sending transport is active or APP_DEBUG=true.', - 'GET /mail/demo/preview is the fastest way to SEE what the MIME builder produces.', - ], - ]); - } - - /** GET /mail/demo/preview — compile the sample message to MIME and show it (no send). */ - public function preview(): Response - { - $to = (string) ($this->request?->input('to', 'recipient@example.com') ?? 'recipient@example.com'); - - try { - $mime = $this->mailer->preview($this->sample($to)); - } catch (MailException $e) { - return $this->unprocessable(['to' => $e->getMessage()]); - } - - return Response::text($mime)->withHeader('Content-Type', 'text/plain; charset=UTF-8'); - } - - /** GET /mail/demo/send — rich API: build + dispatch a Message via the configured transport. */ - public function send(): Response - { - if ($blocked = $this->guard()) { - return $blocked; - } - - $to = $this->recipient(); - if ($to === null) { - return $this->unprocessable(['to' => 'A recipient email is required, e.g. ?to=you@example.com.']); - } - - try { - $this->mailer->dispatch($this->sample($to)); - } catch (MailException $e) { - return $this->unprocessable(['to' => $e->getMessage()]); - } - - return $this->ok(['sent' => true, 'via' => 'MailerContract::dispatch', 'to' => $to, 'transport' => $this->transport()]); - } - - /** GET /mail/demo/queue — rich API: enqueue for background delivery via the QueuePort. */ - public function queue(): Response - { - if ($blocked = $this->guard()) { - return $blocked; - } - - $to = $this->recipient(); - if ($to === null) { - return $this->unprocessable(['to' => 'A recipient email is required, e.g. ?to=you@example.com.']); - } - - try { - $jobId = $this->mailer->enqueue($this->sample($to)); - } catch (MailException $e) { - return $this->unprocessable(['to' => $e->getMessage()]); - } - - return $this->accepted([ - 'queued' => $jobId !== '', - 'via' => 'MailerContract::enqueue', - 'to' => $to, - 'job_id' => $jobId, - 'note' => $jobId === '' - ? 'No QueuePort bound in this request — delivered synchronously instead.' - : 'Enqueued as job "mail.send"; run a worker to deliver it.', - ]); - } - - /** GET /mail/demo/view — kernel MailPort shortcut: send($to, $subject, $view, $data). */ - public function view(): Response - { - if ($blocked = $this->guard()) { - return $blocked; - } - - $to = $this->recipient(); - if ($to === null) { - return $this->unprocessable(['to' => 'A recipient email is required, e.g. ?to=you@example.com.']); - } - - // With the View plugin loaded, arg 3 is a template NAME resolved by - // ViewRendererContract. Here we pass raw HTML, which the Mailer emails as-is - // when no renderer is bound — so MailPort::send() works even without View. - $html = '

Hello from HKM Mail

' - . '

This was delivered through the kernel MailPort::send() shortcut.

' - . '

Sent at ' . htmlspecialchars(date('r'), ENT_QUOTES) . '.

'; - - try { - $this->mail->send($to, 'MailPort demo', $html, ['now' => date('r')]); - } catch (MailException $e) { - dd($e); - return $this->unprocessable(['to' => $e->getMessage()]); - } - - return $this->ok(['sent' => true, 'via' => 'MailPort::send', 'to' => $to, 'transport' => $this->transport()]); - } - - // ── helpers ────────────────────────────────────────────────────────────── - - /** - * The canonical "everything" sample: default From, To/Cc/Bcc, Reply-To, a - * non-ASCII subject (→ MIME encoded-word), HTML + explicit plain-text, an inline - * CID image and a generated CSV attachment, high priority, custom header + tag. - */ - private function sample(string $to): Message - { - // Self-contained 1×1 transparent PNG so the inline-image demo needs no file. - $png = base64_decode( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - true, - ) ?: ''; - - $message = $this->mailer->message(); - - // message() pre-fills From from MAIL_FROM_ADDRESS; supply one for the demo - // when none is configured so every endpoint works out of the box. - if ($message->getFrom() === null) { - $message->from('demo@example.com', 'HKM Mail Demo'); - } - - return $message - ->to($to, 'Demo Recipient') - ->cc('audit@example.com') - ->bcc('hidden@example.com') // delivered, never shown in headers - ->replyTo('support@example.com', 'Support') - ->subject('Your receipt ☕ (HKM Mail demo)') // non-ASCII → RFC 2047 encoded-word - ->html('

Thanks!

Here is your receipt.

logo') - ->text('Thanks! Here is your receipt.') // explicit plain-text alternative - ->embedData($png, 'logo', 'logo.png', 'image/png') // inline image via cid:logo - ->attachData("id,amount\n1,42.00\n", 'receipt.csv', 'text/csv') - ->priority(Priority::High) - ->header('X-Demo', 'mail-plugin') - ->tag('source', 'mail-demo'); - } - - /** Read + require a recipient from the query/body (null when missing). */ - private function recipient(): ?string - { - $to = trim((string) ($this->request?->input('to', '') ?? '')); - - return $to === '' ? null : $to; - } - - /** Active transport name, lower-cased (defaults to smtp). */ - private function transport(): string - { - return strtolower((string) env('MAIL_TRANSPORT', 'smtp')); - } - - /** - * Block real transmission unless it is demonstrably safe: a non-sending transport - * (array/log) or an explicit APP_DEBUG=true. Returns a 403 Response when blocked, - * or null when the caller may proceed. - */ - private function guard(): ?Response - { - $safe = in_array($this->transport(), ['array', 'log'], true) - || filter_var(env('APP_DEBUG', false), FILTER_VALIDATE_BOOL); - - return $safe ? null : $this->forbidden( - 'Live mail sending is disabled for the demo routes. Set MAIL_TRANSPORT=array|log to test ' - . 'safely, or APP_DEBUG=true to allow real delivery.', - ); - } -} diff --git a/plugins/Mail/Infrastructure/Mime/MimeBuilder.php b/plugins/Mail/Infrastructure/Mime/MimeBuilder.php deleted file mode 100644 index 44337ef..0000000 --- a/plugins/Mail/Infrastructure/Mime/MimeBuilder.php +++ /dev/null @@ -1,275 +0,0 @@ -, body: string} */ - public function build(Message $message): array - { - if ($message->getFrom() === null) { - throw new MailException('A message must have a From address.'); - } - if ($message->recipientEmails() === []) { - throw new MailException('A message must have at least one recipient.'); - } - - $root = $this->contentRoot($message); - $headers = $this->topHeaders($message); - foreach ($root['headers'] as $h) { - $headers[] = $h; // Content-Type / -Transfer-Encoding of the root part - } - - // Fold every header so no line exceeds the RFC 5322 limits — a long - // To/Cc list or a long Subject would otherwise be rejected by strict MTAs. - $headers = array_map($this->foldHeader(...), $headers); - - return ['headers' => $headers, 'body' => $root['body']]; - } - - // ── content tree ───────────────────────────────────────────────────────── - - /** @return array{headers: list, body: string} */ - private function contentRoot(Message $m): array - { - $charset = $m->getCharset(); - $html = $m->getHtml(); - $text = $m->getText(); - - if ($html !== '' && $text === '') { - $text = $this->htmlToText($html); // always ship a plain-text alternative - } - - if ($html !== '' && $text !== '') { - $content = $this->multipart('alternative', [ - $this->textPart($text, 'text/plain', $charset), - $this->textPart($html, 'text/html', $charset), - ]); - } elseif ($html !== '') { - $content = $this->textPart($html, 'text/html', $charset); - } else { - $content = $this->textPart($text, 'text/plain', $charset); - } - - $inline = array_values(array_filter($m->getAttachments(), static fn(Attachment $a): bool => $a->inline)); - $regular = array_values(array_filter($m->getAttachments(), static fn(Attachment $a): bool => !$a->inline)); - - if ($inline !== []) { - $rootType = $html !== '' ? 'text/html' : 'text/plain'; - $content = $this->multipart( - 'related', - [$content, ...array_map($this->attachmentPart(...), $inline)], - '; type="' . $rootType . '"', - ); - } - - if ($regular !== []) { - $content = $this->multipart( - 'mixed', - [$content, ...array_map($this->attachmentPart(...), $regular)], - ); - } - - return $content; - } - - // ── leaf parts ─────────────────────────────────────────────────────────── - - /** @return array{headers: list, body: string} */ - private function textPart(string $body, string $type, string $charset): array - { - return [ - 'headers' => [ - 'Content-Type: ' . $type . '; charset=' . $charset, - 'Content-Transfer-Encoding: quoted-printable', - ], - 'body' => $this->quotedPrintable($body), - ]; - } - - /** @return array{headers: list, body: string} */ - private function attachmentPart(Attachment $a): array - { - $headers = [ - 'Content-Type: ' . $a->mimeType . '; name="' . $this->headerParam($a->name) . '"', - 'Content-Transfer-Encoding: base64', - ]; - if ($a->inline) { - $headers[] = 'Content-Disposition: inline; filename="' . $this->headerParam($a->name) . '"'; - $headers[] = 'Content-ID: <' . $a->cid . '>'; - } else { - $headers[] = 'Content-Disposition: attachment; filename="' . $this->headerParam($a->name) . '"'; - } - - return [ - 'headers' => $headers, - 'body' => rtrim(chunk_split(base64_encode($a->contents()), 76, self::EOL), self::EOL), - ]; - } - - // ── multipart composition ──────────────────────────────────────────────── - - /** - * @param list, body: string}> $children - * @return array{headers: list, body: string} - */ - private function multipart(string $subtype, array $children, string $typeParams = ''): array - { - $boundary = 'b1_' . bin2hex(random_bytes(16)); - - $body = ''; - foreach ($children as $child) { - $body .= '--' . $boundary . self::EOL - . implode(self::EOL, $child['headers']) . self::EOL . self::EOL - . $child['body'] . self::EOL; - } - $body .= '--' . $boundary . '--' . self::EOL; - - return [ - 'headers' => ['Content-Type: multipart/' . $subtype . '; boundary="' . $boundary . '"' . $typeParams], - 'body' => $body, - ]; - } - - // ── top-level headers ──────────────────────────────────────────────────── - - /** @return list */ - private function topHeaders(Message $m): array - { - /** @var \Plugins\Mail\Domain\Address $from */ - $from = $m->getFrom(); - $charset = $m->getCharset(); - $headers = []; - - $headers[] = 'Date: ' . date('r'); - $headers[] = 'From: ' . $from->toHeader($charset); - if ($m->getSender() !== null) { - $headers[] = 'Sender: ' . $m->getSender()->toHeader($charset); - } - if ($m->getTo() !== []) { - $headers[] = 'To: ' . $this->addressList($m->getTo(), $charset); - } - if ($m->getCc() !== []) { - $headers[] = 'Cc: ' . $this->addressList($m->getCc(), $charset); - } - // Bcc is deliberately NOT emitted as a header — recipients stay hidden. - if ($m->getReplyTo() !== []) { - $headers[] = 'Reply-To: ' . $this->addressList($m->getReplyTo(), $charset); - } - if ($m->getConfirmReadingTo() !== null) { - $headers[] = 'Disposition-Notification-To: ' . $m->getConfirmReadingTo()->toHeader($charset); - } - - $headers[] = 'Subject: ' . $this->encodeHeaderText($m->getSubject(), $charset); - $headers[] = 'Message-ID: <' . bin2hex(random_bytes(16)) . '@' . $this->hostOf($from->email) . '>'; - $headers[] = 'X-Priority: ' . $m->getPriority()->value . ' (' . $m->getPriority()->label() . ')'; - $headers[] = 'X-Mailer: HKM-Mail'; - $headers[] = 'MIME-Version: 1.0'; - - foreach ($m->getHeaders() as $name => $value) { - $headers[] = $name . ': ' . $value; - } - - return $headers; - } - - /** @param list<\Plugins\Mail\Domain\Address> $addresses */ - private function addressList(array $addresses, string $charset): string - { - return implode(', ', array_map(static fn($a): string => $a->toHeader($charset), $addresses)); - } - - // ── encoders ───────────────────────────────────────────────────────────── - - private function quotedPrintable(string $text): string - { - // Normalise to CRLF, then QP-encode (PHP inserts =\r\n soft breaks). - $text = str_replace(["\r\n", "\r", "\n"], "\n", $text); - $text = str_replace("\n", self::EOL, $text); - - return quoted_printable_encode($text); - } - - /** - * RFC 2047 encoded-word for non-ASCII header text (Subject, etc.). Uses - * mb_encode_mimeheader so long values are split into MULTIPLE ≤75-char - * encoded-words (a single oversized encoded-word is non-conformant and can - * be mangled by receivers). - */ - private function encodeHeaderText(string $text, string $charset): string - { - if (preg_match('/[^\x20-\x7E]/', $text) !== 1) { - return $text; - } - return mb_encode_mimeheader($text, $charset, 'B', self::EOL); - } - - /** - * Fold a completed header line at whitespace so no line exceeds 78 chars - * (soft; the RFC 5322 hard limit is 998). Only existing whitespace is used as - * a fold point (RFC 5322 §2.2.3), so encoded-words and e-mail addresses — - * which contain no spaces — are never split. - */ - private function foldHeader(string $line, int $limit = 78): string - { - // Already-folded (mb_encode_mimeheader) or short lines pass through. - if (strpos($line, self::EOL) !== false || strlen($line) <= $limit) { - return $line; - } - - $out = ''; - $current = ''; - foreach (preg_split('/( )/', $line, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [$line] as $token) { - if ($current !== '' && trim($current) !== '' && strlen($current . $token) > $limit) { - $out .= rtrim($current, ' ') . self::EOL . ' '; - $current = ltrim($token, ' '); - } else { - $current .= $token; - } - } - - return $out . $current; - } - - /** Strip CR/LF/quotes from a header parameter (filename). */ - private function headerParam(string $value): string - { - return (string) preg_replace('/[\r\n"\x00]/', '', $value); - } - - private function hostOf(string $email): string - { - $at = strrpos($email, '@'); - return $at === false ? 'localhost' : substr($email, $at + 1); - } - - private function htmlToText(string $html): string - { - $text = preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', $html) ?? $html; - $text = preg_replace('//i', "\n", $text) ?? $text; - $text = preg_replace('/<\/(p|div|h[1-6]|li|tr)>/i', "\n", $text) ?? $text; - - return trim(html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8')); - } -} diff --git a/plugins/Mail/Infrastructure/Security/DkimSigner.php b/plugins/Mail/Infrastructure/Security/DkimSigner.php deleted file mode 100644 index 7591e1e..0000000 --- a/plugins/Mail/Infrastructure/Security/DkimSigner.php +++ /dev/null @@ -1,106 +0,0 @@ -._domainkey.` TXT. - */ -final readonly class DkimSigner -{ - /** @param list $signedHeaders lower-case header names to sign when present */ - public function __construct( - private string $domain, - private string $selector, - private string $privateKeyPem, - private array $signedHeaders = ['from', 'to', 'cc', 'subject', 'date', 'message-id', 'mime-version', 'content-type'], - ) {} - - /** - * @param list $headers "Name: value" lines - * @return string the DKIM-Signature header line (no trailing CRLF) - */ - public function sign(array $headers, string $body): string - { - $key = openssl_pkey_get_private($this->privateKeyPem); - if ($key === false) { - throw new MailException('DKIM: invalid private key.'); - } - - $bodyHash = base64_encode(hash('sha256', $this->canonicalizeBody($body), true)); - - // Collect the signed headers (last occurrence, in configured order). - $index = $this->indexHeaders($headers); - $names = []; - $canonHeaders = []; - foreach ($this->signedHeaders as $name) { - if (isset($index[$name])) { - $names[] = $name; - $canonHeaders[] = $this->canonicalizeHeader($name, $index[$name]); - } - } - - $dkim = 'v=1; a=rsa-sha256; c=relaxed/relaxed; d=' . $this->domain - . '; s=' . $this->selector - . '; t=' . time() - . '; h=' . implode(':', $names) - . '; bh=' . $bodyHash - . '; b='; - - // The DKIM-Signature header itself is signed with an empty b= and NO CRLF. - $canonHeaders[] = $this->canonicalizeHeader('dkim-signature', $dkim); - $toSign = implode("\r\n", $canonHeaders); - - $signature = ''; - if (openssl_sign($toSign, $signature, $key, OPENSSL_ALGO_SHA256) === false) { - throw new MailException('DKIM: signing failed.'); - } - - return 'DKIM-Signature: ' . $dkim . base64_encode($signature); - } - - /** @param list $headers @return array lower-name => value (last wins) */ - private function indexHeaders(array $headers): array - { - $index = []; - foreach ($headers as $line) { - $pos = strpos($line, ':'); - if ($pos === false) { - continue; - } - $index[strtolower(trim(substr($line, 0, $pos)))] = ltrim(substr($line, $pos + 1)); - } - return $index; - } - - /** Relaxed header canonicalization: lower name, unfold, collapse WSP, trim. */ - private function canonicalizeHeader(string $name, string $value): string - { - $value = preg_replace('/\s+/', ' ', str_replace(["\r\n", "\r", "\n"], ' ', $value)) ?? $value; - - return $name . ':' . trim($value); - } - - /** Relaxed body canonicalization: strip trailing WSP, collapse WSP, drop trailing blank lines. */ - private function canonicalizeBody(string $body): string - { - $body = str_replace(["\r\n", "\r", "\n"], "\n", $body); - $lines = explode("\n", $body); - $lines = array_map( - static fn(string $l): string => rtrim((string) preg_replace('/[ \t]+/', ' ', $l)), - $lines, - ); - $canonical = implode("\r\n", $lines); - $canonical = rtrim($canonical, "\r\n"); - - return $canonical === '' ? '' : $canonical . "\r\n"; - } -} diff --git a/plugins/Mail/Infrastructure/Transport/ArrayTransport.php b/plugins/Mail/Infrastructure/Transport/ArrayTransport.php deleted file mode 100644 index d9649e1..0000000 --- a/plugins/Mail/Infrastructure/Transport/ArrayTransport.php +++ /dev/null @@ -1,42 +0,0 @@ -, mime: string}> */ - private array $sent = []; - - public function send(string $envelopeFrom, array $recipients, string $mime): void - { - $this->sent[] = ['from' => $envelopeFrom, 'recipients' => $recipients, 'mime' => $mime]; - } - - /** @return list, mime: string}> */ - public function messages(): array - { - return $this->sent; - } - - /** @return array{from: string, recipients: list, mime: string}|null */ - public function last(): ?array - { - return $this->sent[array_key_last($this->sent)] ?? null; - } - - public function count(): int - { - return count($this->sent); - } - - public function flush(): void - { - $this->sent = []; - } -} diff --git a/plugins/Mail/Infrastructure/Transport/LogTransport.php b/plugins/Mail/Infrastructure/Transport/LogTransport.php deleted file mode 100644 index c47375f..0000000 --- a/plugins/Mail/Infrastructure/Transport/LogTransport.php +++ /dev/null @@ -1,27 +0,0 @@ -sink ??= static fn(string $line): bool => error_log($line); - } - - public function send(string $envelopeFrom, array $recipients, string $mime): void - { - $entry = '[mail] from=' . $envelopeFrom - . ' to=' . implode(',', $recipients) . "\n" . $mime; - - ($this->sink)($entry); - } -} diff --git a/plugins/Mail/Infrastructure/Transport/MailTransport.php b/plugins/Mail/Infrastructure/Transport/MailTransport.php deleted file mode 100644 index aecba86..0000000 --- a/plugins/Mail/Infrastructure/Transport/MailTransport.php +++ /dev/null @@ -1,55 +0,0 @@ -parseHeaders($headerBlock); - $to = $headers['to'] ?? implode(', ', $recipients); - $subject = $headers['subject'] ?? ''; - - // mail() takes To + Subject separately; remove them from the header blob. - $remaining = preg_replace('/^(To|Subject):.*(\r\n|$)/mi', '', $headerBlock) ?? $headerBlock; - - // escapeshellarg the envelope: mail() does NOT sanitise the 5th param, and a - // FILTER_VALIDATE_EMAIL-legal quoted local part can contain a space that would - // otherwise split into extra sendmail arguments. - $ok = mail($to, $subject, $body, trim($remaining), '-f' . escapeshellarg($envelopeFrom)); - if ($ok === false) { - throw new MailException('mail(): delivery failed.'); - } - } - - /** @return array lower-name => value (first line only) */ - private function parseHeaders(string $block): array - { - $out = []; - foreach (explode("\r\n", $block) as $line) { - $pos = strpos($line, ':'); - if ($pos !== false && $line[0] !== ' ' && $line[0] !== "\t") { - $out[strtolower(substr($line, 0, $pos))] = ltrim(substr($line, $pos + 1)); - } - } - return $out; - } -} diff --git a/plugins/Mail/Infrastructure/Transport/SendmailTransport.php b/plugins/Mail/Infrastructure/Transport/SendmailTransport.php deleted file mode 100644 index ff8b04d..0000000 --- a/plugins/Mail/Infrastructure/Transport/SendmailTransport.php +++ /dev/null @@ -1,60 +0,0 @@ -assertSafe($envelopeFrom); - foreach ($recipients as $r) { - $this->assertSafe($r); - } - - $cmd = escapeshellcmd($this->binary) . ' -oi' - . ' -f ' . escapeshellarg($envelopeFrom) . ' ' - . implode(' ', array_map('escapeshellarg', $recipients)); - - $process = @proc_open($cmd, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); - if (!is_resource($process)) { - throw new MailException('Sendmail: failed to start ' . $this->binary); - } - - // Single-pass CRLF normalisation — a str_replace(["\r\n","\r","\n"] → "\r\n") - // doubles existing CRLFs (\r\n → \r\r\n\r\n) and corrupts the message. - fwrite($pipes[0], (string) preg_replace('/\r\n|\r|\n/', "\r\n", $mime)); - fclose($pipes[0]); - - // Drain stdout/stderr to EOF BEFORE proc_close: a chatty binary that fills the - // pipe buffer (~64 KB) while we hold the pipes open would otherwise deadlock. - stream_get_contents($pipes[1]); - $stderr = (string) stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - - if (proc_close($process) !== 0) { - $detail = trim($stderr) !== '' ? ': ' . trim($stderr) : ' (non-zero exit status).'; - throw new MailException('Sendmail: delivery failed' . $detail); - } - } - - private function assertSafe(string $address): void - { - if (preg_match('/[\r\n\x00]/', $address) === 1) { - throw new MailException('Sendmail: address contains control characters.'); - } - } -} diff --git a/plugins/Mail/Infrastructure/Transport/SmtpTransport.php b/plugins/Mail/Infrastructure/Transport/SmtpTransport.php deleted file mode 100644 index dc0f76c..0000000 --- a/plugins/Mail/Infrastructure/Transport/SmtpTransport.php +++ /dev/null @@ -1,292 +0,0 @@ - $hosts ordered failover list - * @param 'tls'|'ssl'|'none' $encryption - * @param 'auto'|'plain'|'login'|'cram-md5'|'xoauth2'|'none' $authMode - */ - public function __construct( - private readonly array $hosts, - private readonly int $port = 587, - private readonly string $encryption = 'tls', - private readonly string $username = '', - private readonly string $password = '', - private readonly string $authMode = 'auto', - private readonly string $oauthToken = '', - private readonly string $heloDomain = '', - private readonly int $timeout = 30, - private readonly bool $verifyPeer = true, - private readonly bool $keepAlive = false, - /** Allow AUTH over a plaintext channel — DANGEROUS, off by default. */ - private readonly bool $allowInsecureAuth = false, - ) {} - - public function send(string $envelopeFrom, array $recipients, string $mime): void - { - $this->assertNoInjection($envelopeFrom); - foreach ($recipients as $rcpt) { - $this->assertNoInjection($rcpt); - } - - if ($this->socket === null) { - $this->connect(); - } - - try { - $this->command('MAIL FROM:<' . $envelopeFrom . '>', 250); - foreach ($recipients as $rcpt) { - $this->command('RCPT TO:<' . $rcpt . '>', 250); - } - $this->command('DATA', 354); - $this->write($this->dotStuff($mime) . self::EOL . '.'); - $this->expect(250); - } catch (\Throwable $e) { - $this->close(); - throw $e; - } - - if ($this->keepAlive) { - $this->command('RSET', 250); - } else { - $this->close(); - } - } - - // ── connection / handshake ─────────────────────────────────────────────── - - private function connect(): void - { - $lastError = 'no hosts configured'; - - foreach ($this->hosts as $host) { - try { - $this->open($host); - $this->handshake(); - return; - } catch (\Throwable $e) { - $lastError = $host . ': ' . $e->getMessage(); - $this->close(); - } - } - - throw new MailException('SMTP: could not connect (' . $lastError . ').'); - } - - private function open(string $host): void - { - $this->secured = false; - $scheme = $this->encryption === 'ssl' ? 'ssl://' : 'tcp://'; - $context = stream_context_create(['ssl' => [ - 'verify_peer' => $this->verifyPeer, - 'verify_peer_name' => $this->verifyPeer, - 'allow_self_signed' => !$this->verifyPeer, - 'SNI_enabled' => true, - 'peer_name' => $host, - ]]); - - $socket = @stream_socket_client( - $scheme . $host . ':' . $this->port, - $errno, - $errstr, - (float) $this->timeout, - STREAM_CLIENT_CONNECT, - $context, - ); - if ($socket === false) { - throw new MailException("SMTP connect failed: {$errstr} ({$errno})"); - } - - stream_set_timeout($socket, $this->timeout); - $this->socket = $socket; - $this->secured = $this->encryption === 'ssl'; // implicit TLS - $this->expect(220); // server greeting - } - - private function handshake(): void - { - $helo = $this->heloDomain !== '' ? $this->heloDomain : (gethostname() ?: 'localhost'); - - $ehlo = $this->ehlo($helo); - if ($this->encryption === 'tls') { - // Fail CLOSED: if the server does not advertise STARTTLS we refuse - // rather than silently continue in plaintext (downgrade protection). - if (stripos($ehlo, 'STARTTLS') === false) { - throw new MailException('SMTP: server did not offer STARTTLS; refusing to continue unencrypted.'); - } - $this->command('STARTTLS', 220); - $crypto = @stream_socket_enable_crypto( - $this->socket, - true, - STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, - ); - if ($crypto !== true) { - throw new MailException('SMTP: STARTTLS negotiation failed.'); - } - $this->secured = true; - $ehlo = $this->ehlo($helo); // re-EHLO over the encrypted channel - } - - if ($this->authMode !== 'none' && ($this->username !== '' || $this->oauthToken !== '')) { - // NEVER put credentials on the wire in cleartext unless explicitly forced. - if (!$this->secured && !$this->allowInsecureAuth) { - throw new MailException('SMTP: refusing to send credentials over an unencrypted connection (enable TLS or allow_insecure_auth).'); - } - $this->authenticate($ehlo); - } - } - - /** @return string the raw EHLO response (capability lines) */ - private function ehlo(string $helo): string - { - return $this->command('EHLO ' . $helo, 250); - } - - // ── auth ───────────────────────────────────────────────────────────────── - - private function authenticate(string $ehlo): void - { - $mode = $this->authMode === 'auto' ? $this->negotiateAuth($ehlo) : $this->authMode; - - match ($mode) { - 'xoauth2' => $this->authXoauth2(), - 'login' => $this->authLogin(), - 'cram-md5' => $this->authCramMd5(), - default => $this->authPlain(), - }; - } - - private function negotiateAuth(string $ehlo): string - { - $caps = strtoupper($ehlo); - return match (true) { - $this->oauthToken !== '' && str_contains($caps, 'XOAUTH2') => 'xoauth2', - str_contains($caps, 'CRAM-MD5') => 'cram-md5', - str_contains($caps, 'LOGIN') => 'login', - default => 'plain', - }; - } - - private function authPlain(): void - { - $token = base64_encode("\0" . $this->username . "\0" . $this->password); - $this->command('AUTH PLAIN ' . $token, 235); - } - - private function authLogin(): void - { - $this->command('AUTH LOGIN', 334); - $this->command(base64_encode($this->username), 334); - $this->command(base64_encode($this->password), 235); - } - - private function authCramMd5(): void - { - $challenge = $this->command('AUTH CRAM-MD5', 334); - $decoded = base64_decode(trim(substr($challenge, 4)), true) ?: ''; - $digest = hash_hmac('md5', $decoded, $this->password); - $this->command(base64_encode($this->username . ' ' . $digest), 235); - } - - private function authXoauth2(): void - { - $token = base64_encode( - 'user=' . $this->username . "\x01auth=Bearer " . $this->oauthToken . "\x01\x01", - ); - $this->command('AUTH XOAUTH2 ' . $token, 235); - } - - // ── protocol I/O ───────────────────────────────────────────────────────── - - private function command(string $command, int $expected): string - { - $this->write($command); - return $this->expect($expected); - } - - private function write(string $line): void - { - if ($this->socket === null || fwrite($this->socket, $line . self::EOL) === false) { - throw new MailException('SMTP: write failed.'); - } - } - - private function expect(int $code): string - { - $response = ''; - while (($line = fgets($this->socket ?: null, 515)) !== false) { - $response .= $line; - // Multi-line replies use "250-", the final line uses "250 ". - if (strlen($line) < 4 || $line[3] === ' ') { - break; - } - } - - $status = (int) substr($response, 0, 3); - if ($status !== $code) { - throw new MailException('SMTP: expected ' . $code . ', got: ' . trim($response)); - } - - return $response; - } - - /** SMTP dot-stuffing: a line starting with '.' gets an extra '.'. */ - private function dotStuff(string $mime): string - { - // Normalise ALL line endings to CRLF with a single pass. A str_replace with - // ["\r\n", "\r", "\n"] → "\r\n" is WRONG: it rewrites each CR of an existing - // CRLF and doubles it (\r\n → \r\r\n\r\n), producing a premature blank line - // that ends the header block early — strict MTAs then reject "no From header". - $mime = (string) preg_replace('/\r\n|\r|\n/', self::EOL, $mime); - return (string) preg_replace('/^\./m', '..', $mime); - } - - private function assertNoInjection(string $address): void - { - if (preg_match('/[\r\n\x00]/', $address) === 1) { - throw new MailException('SMTP: address contains illegal control characters.'); - } - } - - private function close(): void - { - if (is_resource($this->socket)) { - @fwrite($this->socket, 'QUIT' . self::EOL); - @fclose($this->socket); - } - $this->socket = null; - } - - public function __destruct() - { - $this->close(); - } -} diff --git a/plugins/Mail/Infrastructure/Transport/Transport.php b/plugins/Mail/Infrastructure/Transport/Transport.php deleted file mode 100644 index 320fc34..0000000 --- a/plugins/Mail/Infrastructure/Transport/Transport.php +++ /dev/null @@ -1,22 +0,0 @@ - $recipients bare addresses for RCPT TO (to+cc+bcc) - * @param string $mime full message (headers + CRLFCRLF + body) - */ - public function send(string $envelopeFrom, array $recipients, string $mime): void; -} diff --git a/plugins/Mail/Provider.php b/plugins/Mail/Provider.php deleted file mode 100644 index 95f7f21..0000000 --- a/plugins/Mail/Provider.php +++ /dev/null @@ -1,161 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [MailPort::class, MailerContract::class]; - } - - public function register(ModuleContainer $container): void - { - $config = $this->config(); - - $container->bindInternal(Transport::class, fn(ModuleContainer $c): Transport => $this->makeTransport($config)); - - $container->bindInternal(MimeBuilder::class, static fn(): MimeBuilder => new MimeBuilder()); - - $container->bind(Mailer::class, function (ModuleContainer $c) use ($config): Mailer { - return new Mailer( - transport: $c->make(Transport::class), - mime: $c->make(MimeBuilder::class), - dkim: $this->makeDkim($config), - views: $c->has(ViewRendererContract::class) ? $c->make(ViewRendererContract::class) : null, - queue: $c->has(QueuePort::class) ? $c->make(QueuePort::class) : null, - fromEmail: (string) ($config['from']['address'] ?? ''), - fromName: (string) ($config['from']['name'] ?? ''), - charset: (string) ($config['charset'] ?? 'UTF-8'), - queueName: (string) ($config['queue'] ?? 'mail'), - ); - }); - - // One instance satisfies MailPort, MailerContract and the concrete class. - $container->bind(MailPort::class, static fn(ModuleContainer $c): Mailer => $c->make(Mailer::class)); - $container->bind(MailerContract::class, static fn(ModuleContainer $c): Mailer => $c->make(Mailer::class)); - - // Background delivery job resolves the same Transport. - $container->bindInternal(SendMailJob::class, static fn(ModuleContainer $c): SendMailJob => - new SendMailJob($c->make(Transport::class))); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Job is declared in module.json; nothing to hook here. - } - - /** @param array $config */ - private function makeTransport(array $config): Transport - { - $smtp = $config['smtp'] ?? []; - - return match ((string) ($config['transport'] ?? 'smtp')) { - 'sendmail' => new SendmailTransport((string) ($config['sendmail']['binary'] ?? '/usr/sbin/sendmail')), - 'mail' => new MailTransport(), - 'array' => new ArrayTransport(), - 'log' => new LogTransport(), - default => new SmtpTransport( - hosts: array_values(array_filter(array_map('trim', explode(',', (string) ($smtp['hosts'] ?? 'localhost'))))), - port: (int) ($smtp['port'] ?? 587), - encryption: (string) ($smtp['encryption'] ?? 'tls'), - username: (string) ($smtp['username'] ?? ''), - password: (string) ($smtp['password'] ?? ''), - authMode: (string) ($smtp['auth_mode'] ?? 'auto'), - oauthToken: (string) ($smtp['oauth_token'] ?? ''), - heloDomain: (string) ($smtp['helo_domain'] ?? ''), - timeout: (int) ($smtp['timeout'] ?? 30), - verifyPeer: (bool) ($smtp['verify_peer'] ?? true), - keepAlive: (bool) ($smtp['keep_alive'] ?? false), - allowInsecureAuth: (bool) ($smtp['allow_insecure_auth'] ?? false), - ), - }; - } - - /** @param array $config */ - private function makeDkim(array $config): ?DkimSigner - { - $dkim = $config['dkim'] ?? []; - $domain = (string) ($dkim['domain'] ?? ''); - $selector = (string) ($dkim['selector'] ?? ''); - $key = (string) ($dkim['private_key'] ?? ''); - - if ($domain === '' || $selector === '' || $key === '') { - return null; - } - if (is_file($key) && is_readable($key)) { - $key = (string) file_get_contents($key); - } - - return new DkimSigner($domain, $selector, $key); - } - - /** - * Mail configuration, from the compiled config manifest. - * - * The manifest deep-merges this plugin's config/mail.php with the project's, - * so a project overriding one key (say mail.from.address) inherits every - * other default instead of having to copy the whole file — which is what the - * previous project-file-REPLACES-plugin-file lookup forced. - * - * Falls back to reading the shipped file directly when no manifest exists, - * so the plugin still works in a unit test that never ran the BootPipeline. - * - * @return array - */ - private function config(): array - { - $config = \function_exists('config') ? config('mail') : null; - - if (\is_array($config) && $config !== []) { - return $config; - } - - /** @var array $fallback */ - $fallback = require __DIR__ . '/config/mail.php'; - - return \is_array($fallback) ? $fallback : []; - } -} diff --git a/plugins/Mail/README.md b/plugins/Mail/README.md deleted file mode 100644 index a6c4070..0000000 --- a/plugins/Mail/README.md +++ /dev/null @@ -1,361 +0,0 @@ -# Mail — Delivery (`solves: mail.delivery`) - -> Namespace **`Plugins\Mail\`** · on-demand GDA module - -A **native, dependency-free** mail stack — no PHPMailer, no Symfony Mailer — that -implements the kernel `MailPort` and adds a rich `MailerContract`. It covers the -feature surface you would expect from PHPMailer (attachments, inline images, -cc/bcc, DKIM, SMTP with TLS + auth) while staying entirely self-contained, so the -native distribution ships without `vendor/`. - ---- - -## Part I — Requirements - -### Module manifest - -| Field | Value | -|---|---| -| `solves` | `mail.delivery` | -| `requires` | **none** — `[]` | -| `exposes` | `MailPort` (kernel port), `MailerContract` | -| `jobs` | `mail.send` → `SendMailJob`, queue `mail` | -| Routes | 5 demo `GET` routes (see Part IV — remove for production) | -| Activation | **on-demand** | - -`requires: []` is deliberate: the plugin uses `ViewRendererContract` and -`QueuePort` **when present** and degrades gracefully when they are not. That -graceful degradation has one sharp edge — see the warning below. - -### Kernel ports and collaborators - -| Dependency | Used for | Required? | -|---|---|---| -| `QueuePort` | `queue()` / `enqueue()` background delivery | optional — without it, `enqueue()` **sends inline** and returns `''` | -| `ViewRendererContract` (`Plugins\View`) | rendering a view name into the HTML body | optional — see the warning | -| `StoragePort` / `DatabasePort` | *not used* — Mail owns no tables and no files | — | - -> ### ⚠️ The view-rendering trap -> -> `MailPort::send($to, $subject, $view, $data)` treats `$view` as a **template -> name** only when a `ViewRendererContract` is bound. When the View plugin is -> **not** loaded for that request, the string is treated as **raw HTML** — so the -> literal text `auth::password-otp` is silently mailed as the message body. -> -> Any route that sends a view-based mail must therefore declare **both**: -> -> ```jsonc -> { "method": "POST", "path": "/…", "handler": "…", -> "requires": ["mail.delivery", "view.rendering"] } -> ``` -> -> Do not rely on another module pulling them in transitively — that breaks the -> moment the other module's `requires[]` changes. - -### Configuration - -Everything lives in `config/mail.php`, every value falling back to `env()`. -Override per project by copying it to `config_path('mail.php')`. - -| Env key | Default | Meaning | -|---|---|---| -| `MAIL_TRANSPORT` | `smtp` | `smtp` · `sendmail` · `mail` · `array` · `log` | -| `MAIL_FROM_ADDRESS` | `''` | default `From:` — a message with no `from()` and no default **throws** | -| `MAIL_FROM_NAME` | `''` | display name for the default sender | -| `MAIL_CHARSET` | `UTF-8` | body + header charset | -| `MAIL_QUEUE` | `mail` | queue name used by `enqueue()`/`queue()` | -| `MAIL_SMTP_HOSTS` | `MAIL_HOST` | **comma-separated** host list — failover, tried in order | -| `MAIL_HOST` | `localhost` | single host (fallback when `MAIL_SMTP_HOSTS` is unset) | -| `MAIL_PORT` | `587` | SMTP port | -| `MAIL_ENCRYPTION` | `tls` | `tls` (STARTTLS) · `ssl` (implicit) · `none` | -| `MAIL_USERNAME` / `MAIL_PASSWORD` | `''` | SMTP AUTH credentials | -| `MAIL_AUTH_MODE` | `auto` | `auto`·`plain`·`login`·`cram-md5`·`xoauth2`·`none` | -| `MAIL_OAUTH_TOKEN` | `''` | bearer token for `xoauth2` | -| `MAIL_HELO_DOMAIN` | `''` | EHLO name (defaults to the local hostname) | -| `MAIL_TIMEOUT` | `30` | socket timeout, seconds | -| `MAIL_VERIFY_PEER` | `true` | **TLS peer verification — leave on** | -| `MAIL_KEEP_ALIVE` | `false` | reuse one SMTP connection across sends | -| `MAIL_ALLOW_INSECURE_AUTH` | `false` | permit AUTH over plaintext — **do not enable** | -| `MAIL_SENDMAIL_BINARY` | `/usr/sbin/sendmail` | sendmail transport binary | -| `MAIL_DKIM_DOMAIN` | `''` | signing domain — empty disables DKIM | -| `MAIL_DKIM_SELECTOR` | `''` | DNS selector | -| `MAIL_DKIM_KEY` | `''` | PEM string **or** path to the private key file | - -Read them with `env()` — **never `getenv()`**. - -### Wiring checklist - -1. Add `Plugins\Mail\Provider::class` to the project's `withModules([...])`. -2. Set at minimum `MAIL_FROM_ADDRESS` (a message with no sender throws). -3. For SMTP: `MAIL_HOST`/`MAIL_SMTP_HOSTS`, `MAIL_PORT`, `MAIL_ENCRYPTION`, - credentials. Leave `MAIL_VERIFY_PEER=true`. -4. Want background delivery? Bind a `QueuePort` and run a worker — the - `mail.send` job is registered by this plugin's `module.json`. -5. Sending a **view**? Ensure `view.rendering` is loaded on that route. -6. Production: delete the five `/mail/demo/*` entries from `routes[]`, or veto - them from the project (`proj.json` → `routePolicy.disable`). - ---- - -## Part II — The two APIs - -| API | Shape | Use when | -|---|---|---| -| **`MailPort`** (kernel) | `send($to, $subject, $view, $data)` · `queue(...)` | any module — the portable, view-based shortcut | -| **`MailerContract`** (this plugin) | `message()` → fluent `Message` → `dispatch()` / `enqueue()` / `preview()` | you need cc/bcc, attachments, inline images, headers, priority | - -Both are the same underlying `Mailer` instance, so they share transport, DKIM -signer and defaults. - -```php -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort; -use Plugins\Mail\API\Contracts\MailerContract; -``` - -Cross-plugin callers should type against `MailPort` (kernel port, no coupling) and -only reach for `MailerContract` when they genuinely need the rich surface. - -### The pipeline - -``` -Message ──compile()──► MimeBuilder ──► DkimSigner (optional) ──► Transport - │ │ - headers + body smtp│sendmail│mail - multipart/mixed array│log - └ related (inline CID) - └ alternative (text + html) -``` - -`compile()` also fills the default `From` and computes the envelope sender -(`Return-Path` → `Sender` → `From`). - ---- - -## Part III — Usage - -### 1. Simple, view-based (`MailPort`) - -```php -$mail->send('customer@example.com', 'Welcome', 'user::emails/verify', ['url' => $url]); -$jobId = $mail->queue($to, 'Welcome', 'user::emails/verify', ['url' => $url]); -``` - -`$to` accepts a string, a list of addresses, or an `email => name` map. `$data` -becomes the template's variables. Remember the `view.rendering` requirement. - -### 2. Rich message (`MailerContract`) - -```php -$mailer->dispatch( - $mailer->message() - ->to('customer@example.com', 'Cust') - ->cc('audit@shop.test') - ->bcc('hidden@shop.test') // delivered, never shown in headers - ->replyTo('support@shop.test') - ->subject('Your receipt ☕') // non-ASCII → RFC 2047 encoded-word - ->html('

Thanks!

') - ->embed('/path/logo.png', 'logo') // inline image referenced by cid: - ->attach('/path/receipt.pdf') - ->priority(\Plugins\Mail\Domain\Priority::High), -); -``` - -### 3. The full `Message` builder - -| Group | Methods | -|---|---| -| Sender | `from(email, name)` · `sender(email, name)` · `returnPath(email)` | -| Recipients | `to()` · `cc()` · `bcc()` · `replyTo()` — call repeatedly to add more | -| Content | `subject()` · `html()` · `text()` · `charset()` | -| Delivery hints | `priority(Priority::High\|Normal\|Low)` · `confirmReadingTo(email, name)` | -| Attachments | `attach(path, name, mime)` · `attachData(raw, name, mime)` | -| Inline | `embed(path, cid, name, mime)` · `embedData(raw, cid, name, mime)` | -| Extras | `header(name, value)` (custom) · `tag(key, value)` (metadata, not emitted) | -| Readers | `getFrom()` · `getTo()` · `getCc()` · `getBcc()` · `getSubject()` · `getHtml()` · `getAttachments()` · `getHeaders()` · `recipientEmails()` · … | - -Every setter returns `$this`. Set only `html()` and a **plain-text alternative is -generated automatically**, so the mail is always `multipart/alternative`. - -```php -$m = $mailer->message() - ->to('a@x.test')->to('b@x.test') // repeat to add - ->subject('Report') - ->html('

See attached

') - ->text('See attached') // explicit alternative - ->attachData($csv, 'report.csv', 'text/csv') // no temp file needed - ->header('X-Campaign', 'july') - ->tag('campaign', 'july'); // metadata for your own code -``` - -### 4. Background delivery - -```php -$jobId = $mailer->enqueue($message); // → job id, or '' when no QueuePort is bound -``` - -The message is compiled to MIME **first**, then the `{from, recipients, mime}` -payload is pushed as the `mail.send` job on the `MAIL_QUEUE` queue. The worker -(`SendMailJob`) only re-opens the transport — it never re-renders, so a template -change between enqueue and delivery cannot alter a queued mail. A malformed -payload returns `JobResult::skipped()` rather than failing the worker. - -> With **no** `QueuePort` bound, `enqueue()` silently sends inline and returns -> `''`. Check for `''` if the job id matters to you. - -### 5. Previewing — no send - -```php -echo $mailer->preview($message); // the exact MIME, DKIM-signed, that would go on the wire -``` - -Ideal for a golden-file test or for eyeballing header folding and encoding. - -### 6. Testing - -```php -$transport = new ArrayTransport(); -$mailer = new Mailer( - transport: $transport, - mime: new MimeBuilder(), - views: $viewRenderer, // omit to treat $view as raw HTML - fromEmail: 'no-reply@example.com', -); - -$mailer->send('user@example.com', 'Hi', 'auth::password-otp', ['otp' => '123456']); - -$transport->count(); // 1 -$transport->last(); // ['from' => …, 'recipients' => [...], 'mime' => …] -$transport->messages(); // every captured message -$transport->flush(); -``` - -Or set `MAIL_TRANSPORT=array` (in-memory) / `MAIL_TRANSPORT=log` (full MIME to the -error log) and nothing leaves the machine. - -### 7. DKIM - -```dotenv -MAIL_DKIM_DOMAIN=example.com -MAIL_DKIM_SELECTOR=mail -MAIL_DKIM_KEY=/etc/ssl/private/dkim.pem # PEM string also accepted -``` - -RSA-SHA256, relaxed/relaxed canonicalisation. Publish the public key at -`._domainkey.`. Leave `MAIL_DKIM_DOMAIN` empty to disable. - -### 8. SMTP failover and connection reuse - -```dotenv -MAIL_SMTP_HOSTS=smtp1.example.com,smtp2.example.com -MAIL_KEEP_ALIVE=true -``` - -Hosts are tried in order until one connects. With keep-alive, several sends in one -request or job reuse a single connection (`RSET` between messages). The queue -worker builds a fresh module scope per job, so reuse is *within* a job — batch a -run of messages into one job to benefit. - ---- - -## Part IV — Reference - -### Transports (`MAIL_TRANSPORT`) - -| Value | Notes | -|---|---| -| `smtp` (default) | Native SMTP. `tls` (STARTTLS) or `ssl` (implicit); AUTH `plain`/`login`/`cram-md5`/`xoauth2` (auto-negotiated); multi-host failover; optional keep-alive | -| `sendmail` | Pipes to the sendmail binary with a `-f` envelope sender | -| `mail` | PHP's `mail()` | -| `array` | Captures in memory — **tests** (`messages()`/`last()`/`count()`/`flush()`) | -| `log` | Writes the full MIME to the log — **dev** | - -All implement `Infrastructure\Transport\Transport`: -`send(string $envelopeFrom, array $recipients, string $mime): void`. Add your own -(an API-based provider, say) by implementing it and binding it in the container. - -### Demo routes - -Self-contained [`MailDemoController`](Infrastructure/Http/MailDemoController.php) -wired to five `GET` routes so you can exercise every path from a browser. -**For learning/testing — remove the `routes[]` entries or gate them behind `auth` -before production.** - -| Route | Shows | Sends? | -|---|---|---| -| `GET /mail/demo` | overview — active transport + endpoint map | no | -| `GET /mail/demo/preview?to=…` | `preview()` — the raw MIME | no | -| `GET /mail/demo/send?to=…` | `dispatch()` — rich message (cc/bcc, inline image, attachment) | yes | -| `GET /mail/demo/queue?to=…` | `enqueue()` — returns the job id | yes | -| `GET /mail/demo/view?to=…` | `MailPort::send()` — the view-based shortcut | yes | - -**Safety guard:** `send`/`queue`/`view` return **403** unless a non-sending -transport (`array`/`log`) is active **or** `APP_DEBUG=true` — an accidentally -enabled demo can never become an open relay. `preview` never sends. - -```bash -export MAIL_TRANSPORT=log # nothing leaves the box -curl "http://localhost:8000/mail/demo/preview?to=you@example.com" -curl "http://localhost:8000/mail/demo/send?to=you@example.com" -``` - -### Security (defaults are security-first) - -- **Header-injection proof** — every address, name, custom header and attachment - filename is rejected if it contains CR/LF/NUL (`Address`, `Message::header`, - `MimeBuilder`, transports). An attacker cannot smuggle a `Bcc:` through a - user-supplied field. -- **BCC never leaks** — recipients get the mail via the envelope; `Bcc:` is never - emitted as a header. -- **TLS peer verification ON by default** (`MAIL_VERIFY_PEER`). -- **Fail-closed STARTTLS** — if the server does not advertise STARTTLS the - connection is refused, never downgraded to plaintext. -- **No cleartext credential leak** — SMTP AUTH is refused over an unencrypted - channel unless `MAIL_ALLOW_INSECURE_AUTH=true` is explicitly set. -- **SMTP command injection** blocked (envelope/RCPT re-validated before the wire). -- **DKIM** RSA-SHA256, relaxed/relaxed. - -### Robustness - -- **RFC 5322 header folding** — no header line exceeds 998 chars (folded at - whitespace), so long To/Cc lists and Subjects survive strict MTAs. -- **RFC 2047 encoded-words** — non-ASCII Subjects/names split into multiple - ≤75-char encoded-words, never one oversized blob. -- **Auto plain-text alternative** generated from HTML. -- **Fast path preserved** — short ASCII headers skip MIME-encoding and folding - entirely; encoding kicks in only when a value needs it. - -### Errors - -Everything the plugin throws is `Plugins\Mail\Domain\MailException` -(`\RuntimeException`): no `From` address, an address failing the CR/LF guard, an -unreadable attachment, an SMTP handshake/AUTH failure, a DKIM key that will not -load. Callers that treat mail as non-critical (the Auth password flows, for -instance) catch `\Throwable` and carry on. - -### Layout - -``` -API/Contracts/MailerContract message() · dispatch() · enqueue() · preview() -Application/Mailer MailPort + MailerContract; compile → DKIM → transport/queue -Application/Jobs/SendMailJob background delivery (job name "mail.send") -Domain/ Message (builder) · Address (CRLF guard) · Attachment · Priority · MailException -Infrastructure/Mime/MimeBuilder multipart mixed/related/alternative + QP/base64 encoders -Infrastructure/Security/DkimSigner RSA-SHA256 relaxed/relaxed -Infrastructure/Transport/ Transport + Smtp/Sendmail/Mail/Array/Log -Infrastructure/Http/MailDemoController demo routes (GET /mail/demo/*) — remove for prod -config/mail.php all MAIL_* configuration -``` - -### Rules - -**Do** — type cross-plugin callers against `MailPort` · declare -`"requires": ["mail.delivery", "view.rendering"]` on any route that mails a view · -keep `MAIL_VERIFY_PEER=true` · use `array`/`log` transports in tests and dev · -`queue()` anything on a request path · treat mail failure as non-fatal in flows -that already committed their real work. - -**Don't** — enable `MAIL_ALLOW_INSECURE_AUTH` · ship the `/mail/demo/*` routes · -put user input into a header without going through `Message::header()` · -`getenv()` a `MAIL_*` value · assume `enqueue()` returned a job id without a -`QueuePort` bound · pass view data as the renderer's second argument — that -parameter is render *options*; template variables go through `setData()`. diff --git a/plugins/Mail/config/mail.php b/plugins/Mail/config/mail.php deleted file mode 100644 index f76c233..0000000 --- a/plugins/Mail/config/mail.php +++ /dev/null @@ -1,49 +0,0 @@ - env('MAIL_TRANSPORT', 'smtp'), - - 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', ''), - 'name' => env('MAIL_FROM_NAME', ''), - ], - - 'charset' => env('MAIL_CHARSET', 'UTF-8'), - 'queue' => env('MAIL_QUEUE', 'mail'), - - 'smtp' => [ - // Comma-separated for failover, e.g. "smtp1.example.com,smtp2.example.com". - 'hosts' => env('MAIL_SMTP_HOSTS', env('MAIL_HOST', 'localhost')), - 'port' => (int) env('MAIL_PORT', 587), - 'encryption' => env('MAIL_ENCRYPTION', 'tls'), // tls | ssl | none - 'username' => env('MAIL_USERNAME', ''), - 'password' => env('MAIL_PASSWORD', ''), - 'auth_mode' => env('MAIL_AUTH_MODE', 'auto'), // auto|plain|login|cram-md5|xoauth2|none - 'oauth_token' => env('MAIL_OAUTH_TOKEN', ''), - 'helo_domain' => env('MAIL_HELO_DOMAIN', ''), - 'timeout' => (int) env('MAIL_TIMEOUT', 30), - 'verify_peer' => filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOL), - 'keep_alive' => filter_var(env('MAIL_KEEP_ALIVE', 'false'), FILTER_VALIDATE_BOOL), - // Security: NEVER auth over plaintext unless explicitly forced. - 'allow_insecure_auth' => filter_var(env('MAIL_ALLOW_INSECURE_AUTH', 'false'), FILTER_VALIDATE_BOOL), - ], - - 'sendmail' => [ - 'binary' => env('MAIL_SENDMAIL_BINARY', '/usr/sbin/sendmail'), - ], - - // DKIM signing — leave domain/selector/key empty to disable. - 'dkim' => [ - 'domain' => env('MAIL_DKIM_DOMAIN', ''), - 'selector' => env('MAIL_DKIM_SELECTOR', ''), - // PEM string OR a path to the private key file. - 'private_key' => env('MAIL_DKIM_KEY', ''), - ], -]; diff --git a/plugins/Mail/module.json b/plugins/Mail/module.json deleted file mode 100644 index 14b9c71..0000000 --- a/plugins/Mail/module.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "mail", - "version": "1.0.0", - "solves": "mail.delivery", - "type": "module", - - "requires": [], - "exposes": [ - "AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\MailPort", - "Plugins\\Mail\\API\\Contracts\\MailerContract" - ], - - "routes": [ - { "method": "GET", "path": "/mail/demo", "handler": "Plugins\\Mail\\Infrastructure\\Http\\MailDemoController@index" }, - { "method": "GET", "path": "/mail/demo/preview", "handler": "Plugins\\Mail\\Infrastructure\\Http\\MailDemoController@preview" }, - { "method": "GET", "path": "/mail/demo/send", "handler": "Plugins\\Mail\\Infrastructure\\Http\\MailDemoController@send" }, - { "method": "GET", "path": "/mail/demo/queue", "handler": "Plugins\\Mail\\Infrastructure\\Http\\MailDemoController@queue" }, - { "method": "GET", "path": "/mail/demo/view", "handler": "Plugins\\Mail\\Infrastructure\\Http\\MailDemoController@view" } - ], - "emits": [], - "listens": [], - - "jobs": [ - { "name": "mail.send", "handler": "Plugins\\Mail\\Application\\Jobs\\SendMailJob", "queue": "mail" } - ], - - "documentation": "The Mail plugin — a native, dependency-free MailPort adapter + rich MailerContract. Transports: SMTP (TLS/STARTTLS, AUTH PLAIN/LOGIN/CRAM-MD5/XOAUTH2, multi-host failover, keep-alive), Sendmail, PHP mail(), Array/Log (test/dev). Message API: from/sender/return-path, to/cc/bcc, reply-to, HTML + auto plain-text alternative, file/raw/inline-CID attachments, custom headers, priority, read receipts, charset. Security: CR/LF header-injection guards on every address/header/param, TLS peer verification, DKIM RSA-SHA256 signing (relaxed/relaxed), hidden BCC. send()/queue() (queue via QueuePort + the mail.send job); views render through ViewRendererContract when present, else the view string is treated as raw HTML. Config in config/mail.php (MAIL_* env).", - - "config": [ - { "key": "MAIL_TRANSPORT", "type": "string", "required": false }, - { "key": "MAIL_FROM_ADDRESS", "type": "string", "required": false }, - { "key": "MAIL_FROM_NAME", "type": "string", "required": false }, - { "key": "MAIL_HOST", "type": "string", "required": false }, - { "key": "MAIL_SMTP_HOSTS", "type": "string", "required": false }, - { "key": "MAIL_PORT", "type": "int", "required": false }, - { "key": "MAIL_ENCRYPTION", "type": "string", "required": false }, - { "key": "MAIL_USERNAME", "type": "string", "required": false }, - { "key": "MAIL_PASSWORD", "type": "string", "required": false }, - { "key": "MAIL_AUTH_MODE", "type": "string", "required": false }, - { "key": "MAIL_OAUTH_TOKEN", "type": "string", "required": false }, - { "key": "MAIL_HELO_DOMAIN", "type": "string", "required": false }, - { "key": "MAIL_TIMEOUT", "type": "int", "required": false }, - { "key": "MAIL_VERIFY_PEER", "type": "bool", "required": false }, - { "key": "MAIL_KEEP_ALIVE", "type": "bool", "required": false }, - { "key": "MAIL_ALLOW_INSECURE_AUTH", "type": "bool", "required": false }, - { "key": "MAIL_SENDMAIL_BINARY","type": "string", "required": false }, - { "key": "MAIL_DKIM_DOMAIN", "type": "string", "required": false }, - { "key": "MAIL_DKIM_SELECTOR", "type": "string", "required": false }, - { "key": "MAIL_DKIM_KEY", "type": "string", "required": false }, - { "key": "MAIL_CHARSET", "type": "string", "required": false }, - { "key": "MAIL_QUEUE", "type": "string", "required": false } - ] -} diff --git a/plugins/OAuth2/Application/Ports/AuthCodeStore.php b/plugins/OAuth2/Application/Ports/AuthCodeStore.php deleted file mode 100644 index b6fa013..0000000 --- a/plugins/OAuth2/Application/Ports/AuthCodeStore.php +++ /dev/null @@ -1,22 +0,0 @@ - $params client_id, redirect_uri, scope, state, - * code_challenge, code_challenge_method. - * response_type defaults to 'code'. - * @return array{code:string,state:string,redirect_uri:string} - * @throws OAuthException when the client/redirect/scope/PKCE is invalid - */ - public function issueCodeFor(array $params, string $userId): array; -} diff --git a/plugins/OAuth2/Application/Ports/ClientStore.php b/plugins/OAuth2/Application/Ports/ClientStore.php deleted file mode 100644 index a5baaf2..0000000 --- a/plugins/OAuth2/Application/Ports/ClientStore.php +++ /dev/null @@ -1,53 +0,0 @@ - $redirectUris - * @param list $grantTypes - * @param list $scopes - */ - public function create( - string $id, - string $name, - ?string $secretHash, - array $redirectUris, - array $grantTypes, - array $scopes, - bool $confidential, - ?string $ownerId = null, - ): void; - - /** @return list */ - public function all(): array; - - /** - * Clients registered by a given user (self-service management). - * - * @return list - */ - public function findByOwner(string $ownerId): array; - - /** - * Update a client's editable details (name/redirects/scopes). False when no - * such client. - * - * @param list $redirectUris - * @param list $scopes - */ - public function updateDetails(string $id, string $name, array $redirectUris, array $scopes): bool; - - /** Mark a client revoked (its tokens stop being issued/accepted). */ - public function revoke(string $id): bool; - - /** Replace a confidential client's secret hash (secret rotation). */ - public function updateSecret(string $id, string $secretHash): bool; -} diff --git a/plugins/OAuth2/Application/Ports/DeviceCodeStore.php b/plugins/OAuth2/Application/Ports/DeviceCodeStore.php deleted file mode 100644 index 61d9702..0000000 --- a/plugins/OAuth2/Application/Ports/DeviceCodeStore.php +++ /dev/null @@ -1,30 +0,0 @@ - - */ - public function findByUser(string $userId): array; - - /** - * Every active (non-revoked, non-expired) refresh token in the tenant — - * ADMIN view of all authorized grants across users. - * - * @return list - */ - public function allActive(): array; - - /** Atomically revoke if currently active; false when it was already revoked. */ - public function revokeIfActive(string $tokenId): bool; - - /** Revoke every token in a rotation family (reuse-detection response). */ - public function revokeFamily(string $familyId): int; - - public function deleteExpired(?\DateTimeImmutable $now = null): int; -} diff --git a/plugins/OAuth2/Application/Ports/ResourceOwnerVerifier.php b/plugins/OAuth2/Application/Ports/ResourceOwnerVerifier.php deleted file mode 100644 index b254e04..0000000 --- a/plugins/OAuth2/Application/Ports/ResourceOwnerVerifier.php +++ /dev/null @@ -1,17 +0,0 @@ - all registered scope identifiers */ - public function all(): array; - - /** - * The scope catalogue with human-readable descriptions (consent screens + - * the /oauth/scopes endpoint). - * - * @return array id => description ('' when none stored) - */ - public function describe(): array; - - /** Register or update a grantable scope (admin catalogue management). */ - public function put(string $id, string $description): void; - - /** Remove a scope from the catalogue. False when it did not exist. */ - public function delete(string $id): bool; -} diff --git a/plugins/OAuth2/Application/Ports/UserInfoProvider.php b/plugins/OAuth2/Application/Ports/UserInfoProvider.php deleted file mode 100644 index 5b3d42b..0000000 --- a/plugins/OAuth2/Application/Ports/UserInfoProvider.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ -interface UserInfoProvider -{ - /** - * @param list $scopes - * @return array claims including at least `sub` - */ - public function claims(string $userId, array $scopes): array; -} diff --git a/plugins/OAuth2/Application/Services/AuthorizationRequest.php b/plugins/OAuth2/Application/Services/AuthorizationRequest.php deleted file mode 100644 index c41bb51..0000000 --- a/plugins/OAuth2/Application/Services/AuthorizationRequest.php +++ /dev/null @@ -1,44 +0,0 @@ - $scopes */ - public function __construct( - public readonly Client $client, - public readonly string $redirectUri, - public readonly array $scopes, - public readonly string $state, - public readonly ?string $codeChallenge, - public readonly ?string $codeChallengeMethod, - public readonly ?string $nonce, - ) { - } - - /** Serialise to a hidden-field map for the consent form round-trip. */ - public function toFormState(): array - { - return [ - // validate() re-checks the full parameter set on the consent POST, - // including response_type — omitting it made every approval fail - // with unsupported_response_type. - 'response_type' => 'code', - 'client_id' => $this->client->id, - 'redirect_uri' => $this->redirectUri, - 'scope' => implode(' ', $this->scopes), - 'state' => $this->state, - 'code_challenge' => (string) $this->codeChallenge, - 'code_challenge_method' => (string) $this->codeChallengeMethod, - 'nonce' => (string) $this->nonce, - ]; - } -} diff --git a/plugins/OAuth2/Application/Services/AuthorizationService.php b/plugins/OAuth2/Application/Services/AuthorizationService.php deleted file mode 100644 index 1265d1c..0000000 --- a/plugins/OAuth2/Application/Services/AuthorizationService.php +++ /dev/null @@ -1,163 +0,0 @@ - $params query parameters from /authorize - * @throws OAuthException - */ - public function validate(array $params): AuthorizationRequest - { - $clientId = trim($params['client_id'] ?? ''); - if ($clientId === '') { - throw OAuthException::invalidRequest('Missing client_id.'); - } - - $client = $this->clients->find($clientId); - if ($client === null || $client->revoked) { - throw OAuthException::invalidClient('Unknown or revoked client.'); - } - - // Resolve + EXACT-match the redirect URI before trusting any redirect. - $redirectUri = trim($params['redirect_uri'] ?? ''); - if ($redirectUri === '') { - $redirectUri = $client->defaultRedirect() ?? ''; - if ($redirectUri === '' || count($client->redirectUris) !== 1) { - throw OAuthException::invalidRequest('A redirect_uri is required.'); - } - } - if (!$client->allowsRedirect($redirectUri)) { - throw OAuthException::invalidRequest('redirect_uri does not match a registered URI.'); - } - - if (!$client->allowsGrant('authorization_code')) { - throw OAuthException::unauthorizedClient('Client may not use the authorization_code grant.'); - } - - // From here errors are redirectable (we trust redirect_uri now). - $responseType = trim($params['response_type'] ?? ''); - if ($responseType !== 'code') { - throw OAuthException::unsupportedResponseType(); - } - - $scopes = $this->scopeValidator->validate($params['scope'] ?? '', $client); - - // PKCE. - $challenge = trim($params['code_challenge'] ?? ''); - $method = trim($params['code_challenge_method'] ?? Pkce::METHOD_PLAIN); - if ($challenge === '') { - if ($client->isPublic()) { - throw OAuthException::invalidRequest('PKCE code_challenge is required for public clients.'); - } - $challenge = null; - $method = null; - } elseif (!Pkce::supportsMethod($method)) { - throw OAuthException::invalidRequest('Unsupported code_challenge_method.'); - } - - return new AuthorizationRequest( - client: $client, - redirectUri: $redirectUri, - scopes: $scopes, - state: (string) ($params['state'] ?? ''), - codeChallenge: $challenge, - codeChallengeMethod: $method, - nonce: ($params['nonce'] ?? null) ?: null, - ); - } - - /** - * Issue an authorization code for an APPROVED request and return the full - * redirect URL (with code + state) the user-agent should be sent to. - */ - public function issueCode(AuthorizationRequest $req, string $userId): string - { - $rawCode = bin2hex(random_bytes(32)); - $codeId = bin2hex(random_bytes(16)); - $expires = (new \DateTimeImmutable())->add(new \DateInterval('PT' . max(30, $this->codeTtl) . 'S')); - - $code = AuthCode::of( - id: $codeId, - clientId: $req->client->id, - userId: $userId, - redirectUri: $req->redirectUri, - scopes: $req->scopes, - codeChallenge: $req->codeChallenge, - codeChallengeMethod: $req->codeChallengeMethod, - expiresAt: $expires, - nonce: $req->nonce, - ); - - $this->codes->store($code, hash('sha256', $rawCode)); - - return $this->buildRedirect($req->redirectUri, [ - 'code' => $rawCode, - 'state' => $req->state, - ]); - } - - /** - * Headless validate + issue for a first-party, already-authenticated user - * (AuthorizationFlow port — the old __DEV__ mobile login/register flow). - * Consent is skipped; every other check (client, redirect_uri exact match, - * scopes, PKCE-for-public-clients) still runs. - */ - public function issueCodeFor(array $params, string $userId): array - { - $params['response_type'] = $params['response_type'] ?? 'code'; - - $request = $this->validate($params); - $redirect = $this->issueCode($request, $userId); - - parse_str((string) parse_url($redirect, PHP_URL_QUERY), $query); - - return [ - 'code' => (string) ($query['code'] ?? ''), - 'state' => (string) ($query['state'] ?? $request->state), - 'redirect_uri' => $request->redirectUri, - ]; - } - - /** Build a redirect URL, appending params to any existing query string. */ - public function buildRedirect(string $uri, array $params): string - { - $params = array_filter($params, static fn ($v) => $v !== '' && $v !== null); - $sep = str_contains($uri, '?') ? '&' : '?'; - - return $params === [] ? $uri : $uri . $sep . http_build_query($params); - } -} diff --git a/plugins/OAuth2/Application/Services/DeviceService.php b/plugins/OAuth2/Application/Services/DeviceService.php deleted file mode 100644 index bc2e505..0000000 --- a/plugins/OAuth2/Application/Services/DeviceService.php +++ /dev/null @@ -1,89 +0,0 @@ -clients->find($clientId); - if ($client === null || $client->revoked) { - throw OAuthException::invalidClient(); - } - if (!$client->allowsGrant('urn:ietf:params:oauth:grant-type:device_code')) { - throw OAuthException::unauthorizedClient('Client may not use the device grant.'); - } - - $scopes = $this->scopeValidator->validate($params['scope'] ?? '', $client); - - $rawDeviceCode = bin2hex(random_bytes(40)); - $userCode = $this->generateUserCode(); - $expires = (new \DateTimeImmutable())->add(new \DateInterval('PT' . max(60, $this->ttl) . 'S')); - - $device = DeviceCode::of( - id: bin2hex(random_bytes(16)), - userCode: $userCode, - clientId: $client->id, - scopes: $scopes, - status: DeviceCode::PENDING, - userId: null, - interval: $this->interval, - lastPolledAt: null, - expiresAt: $expires, - ); - $this->devices->store($device, hash('sha256', $rawDeviceCode)); - - return [ - 'device_code' => $rawDeviceCode, - 'user_code' => $userCode, - 'expires_in' => max(60, $this->ttl), - 'interval' => $this->interval, - 'scope' => implode(' ', $scopes), - ]; - } - - /** e.g. "BCDF-GHJK" — 8 chars in two readable groups. */ - private function generateUserCode(): string - { - $chars = ''; - for ($i = 0; $i < 8; $i++) { - $chars .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)]; - } - - return substr($chars, 0, 4) . '-' . substr($chars, 4, 4); - } -} diff --git a/plugins/OAuth2/Application/Services/IntrospectionService.php b/plugins/OAuth2/Application/Services/IntrospectionService.php deleted file mode 100644 index 8a3d448..0000000 --- a/plugins/OAuth2/Application/Services/IntrospectionService.php +++ /dev/null @@ -1,110 +0,0 @@ - */ - public function introspect(string $token): array - { - if ($token === '') { - return ['active' => false]; - } - - // 1. Try as a JWT access token. - try { - $claims = (array) JWT::decode($token, new Key($this->verifyKey, $this->algo)); - - return [ - 'active' => true, - 'token_type' => 'access_token', - 'scope' => $claims['scope'] ?? '', - 'client_id' => $claims['client_id'] ?? null, - 'sub' => $claims['sub'] ?? null, - 'exp' => $claims['exp'] ?? null, - 'iat' => $claims['iat'] ?? null, - 'iss' => $claims['iss'] ?? null, - 'aud' => $claims['aud'] ?? null, - 'jti' => $claims['jti'] ?? null, - ]; - } catch (\Throwable) { - // not a (valid) JWT — fall through to opaque refresh lookup - } - - // 2. Try as an opaque refresh token. - $record = $this->refreshTokens->findByHash($this->issuer->hash($token)); - if ($record !== null && !$record->revoked && !$record->isExpired()) { - return [ - 'active' => true, - 'token_type' => 'refresh_token', - 'scope' => implode(' ', $record->scopes), - 'client_id' => $record->clientId, - 'sub' => $record->userId, - 'exp' => $record->expiresAt->getTimestamp(), - ]; - } - - return ['active' => false]; - } - - /** - * RFC 7009 revocation. Handles BOTH token types: - * - opaque refresh token → revoke the whole rotation family; - * - JWT access token → deny-list its `jti` (same list the platform - * JwtAuthLayer consults), so it stops authenticating before its natural - * expiry. - * Per the RFC, an unknown/unsupported token still returns success. - */ - public function revoke(string $token): void - { - if ($token === '') { - return; - } - - // Access token (JWT): deny-list the jti until it would have expired. - try { - $claims = (array) JWT::decode($token, new Key($this->verifyKey, $this->algo)); - $jti = (string) ($claims['jti'] ?? ''); - if ($jti !== '' && $this->revocations !== null) { - $ttl = max(1, (int) ($claims['exp'] ?? 0) - time()); - $this->revocations->set(self::JWT_REVOCATION_PREFIX . $jti, 1, $ttl); - } - - return; - } catch (\Throwable) { - // not a JWT — treat as an opaque refresh token below - } - - $record = $this->refreshTokens->findByHash($this->issuer->hash($token)); - if ($record !== null) { - $this->refreshTokens->revokeFamily($record->familyId); - } - } -} diff --git a/plugins/OAuth2/Application/Services/ScopeRegistry.php b/plugins/OAuth2/Application/Services/ScopeRegistry.php deleted file mode 100644 index a7d2f61..0000000 --- a/plugins/OAuth2/Application/Services/ScopeRegistry.php +++ /dev/null @@ -1,85 +0,0 @@ -|null memoised catalogue for this request */ - private ?array $catalogue = null; - - public function __construct(private readonly ScopeStore $scopes) {} - - /** - * The full catalogue as a list of {id, description} rows. - * - * @return list - */ - public function scopes(): array - { - $out = []; - foreach ($this->describe() as $id => $description) { - $out[] = ['id' => $id, 'description' => $description]; - } - - return $out; - } - - /** - * The catalogue rows for a specific set of scope ids (unknown ids dropped). - * - * @param list $ids - * @return list - */ - public function scopesFor(array $ids): array - { - $catalogue = $this->describe(); - $out = []; - foreach ($ids as $id) { - if (array_key_exists($id, $catalogue)) { - $out[] = ['id' => $id, 'description' => $catalogue[$id]]; - } - } - - return $out; - } - - /** True when the scope id is registered/grantable. */ - public function hasScope(string $id): bool - { - return array_key_exists($id, $this->describe()); - } - - /** - * True when EVERY requested scope is registered (the guard the token - * endpoint uses before issuing). An empty request is always allowed. - * - * @param list $scopes - */ - public function tokensCan(array $scopes): bool - { - foreach ($scopes as $scope) { - if (!$this->hasScope($scope)) { - return false; - } - } - - return true; - } - - /** @return array */ - private function describe(): array - { - return $this->catalogue ??= $this->scopes->describe(); - } -} diff --git a/plugins/OAuth2/Application/Services/ScopeValidator.php b/plugins/OAuth2/Application/Services/ScopeValidator.php deleted file mode 100644 index 7bff367..0000000 --- a/plugins/OAuth2/Application/Services/ScopeValidator.php +++ /dev/null @@ -1,47 +0,0 @@ - - * @throws OAuthException invalid_scope when a scope is unknown or not allowed for the client. - */ - public function validate(?string $requested, Client $client): array - { - $requested = trim((string) $requested); - - // No scope requested → fall back to the client's registered scopes (or none). - if ($requested === '') { - return array_values($client->scopes); - } - - $list = array_values(array_filter(preg_split('/\s+/', $requested) ?: [])); - - foreach ($list as $scope) { - if (!$this->scopes->exists($scope)) { - throw OAuthException::invalidScope("Unknown scope: {$scope}."); - } - if ($client->scopes !== [] && !in_array($scope, $client->scopes, true)) { - throw OAuthException::invalidScope("Scope not allowed for this client: {$scope}."); - } - } - - return $list; - } -} diff --git a/plugins/OAuth2/Application/Services/TokenIssuer.php b/plugins/OAuth2/Application/Services/TokenIssuer.php deleted file mode 100644 index 75634d1..0000000 --- a/plugins/OAuth2/Application/Services/TokenIssuer.php +++ /dev/null @@ -1,121 +0,0 @@ -algo[0] ?? 'H'; - - return $c === 'R' || $c === 'E' || $c === 'P'; - } - - /** - * @param list $scopes - * @return array{token:string, jti:string, expires_in:int} - */ - public function accessToken(string $subject, string $clientId, array $scopes, string $tenantId = ''): array - { - $now = time(); - $jti = bin2hex(random_bytes(16)); - - // Audience is the RESOURCE SERVER (so platform JwtAuthLayer audience checks - // pass); `azp` records the authorized client. Falls back to client_id when - // no resource audience is configured. - $audience = ($this->audience !== null && $this->audience !== '') ? $this->audience : $clientId; - - $payload = array_filter([ - 'iss' => $this->issuer, - 'aud' => $audience, - 'azp' => $clientId, - 'sub' => $subject, - 'client_id' => $clientId, - 'scope' => implode(' ', $scopes), - // Namespaced so OAuth scopes can never be mistaken for RBAC permissions. - 'permissions' => array_map(static fn (string $s): string => 'scope:' . $s, array_values($scopes)), - 'tnt' => $tenantId, - 'iat' => $now, - 'nbf' => $now, - 'exp' => $now + $this->accessTtl, - 'jti' => $jti, - ], static fn ($v) => $v !== null); - - $key = $this->isAsymmetric() ? (string) $this->privateKey : $this->secret; - $kid = ($this->keyId !== null && $this->keyId !== '') ? $this->keyId : null; - - return [ - 'token' => JWT::encode($payload, $key, $this->algo, $kid), - 'jti' => $jti, - 'expires_in' => $this->accessTtl, - ]; - } - - /** - * Mint an OpenID Connect id_token (OIDC Core §2) — issued when the `openid` - * scope is granted. Signed with the same key as access tokens. - */ - public function idToken(string $subject, string $clientId, ?string $nonce = null, ?int $authTime = null): string - { - $now = time(); - $payload = array_filter([ - 'iss' => $this->issuer, - 'sub' => $subject, - 'aud' => $clientId, - 'iat' => $now, - 'exp' => $now + $this->accessTtl, - 'auth_time' => $authTime, - 'nonce' => $nonce, - ], static fn ($v) => $v !== null); - - $key = $this->isAsymmetric() ? (string) $this->privateKey : $this->secret; - $kid = ($this->keyId !== null && $this->keyId !== '') ? $this->keyId : null; - - return JWT::encode($payload, $key, $this->algo, $kid); - } - - /** A cryptographically-random opaque refresh token (raw — store only its hash). */ - public function refreshToken(): string - { - return bin2hex(random_bytes(40)); - } - - public function hash(string $raw): string - { - return hash('sha256', $raw); - } - - public function accessTtl(): int - { - return $this->accessTtl; - } -} diff --git a/plugins/OAuth2/Application/Services/TokenService.php b/plugins/OAuth2/Application/Services/TokenService.php deleted file mode 100644 index 043431c..0000000 --- a/plugins/OAuth2/Application/Services/TokenService.php +++ /dev/null @@ -1,318 +0,0 @@ - $params POST body params. - * @param array{0:string,1:string}|null $basic [clientId, clientSecret] from Basic auth, if present. - * @return array the token response body. - * @throws OAuthException - */ - public function handle(array $params, ?array $basic): array - { - $grant = GrantType::tryFromString(trim($params['grant_type'] ?? '')); - if ($grant === null) { - throw OAuthException::unsupportedGrantType(); - } - - return match ($grant) { - GrantType::AuthorizationCode => $this->authorizationCode($params, $basic), - GrantType::ClientCredentials => $this->clientCredentials($params, $basic), - GrantType::RefreshToken => $this->refreshToken($params, $basic), - GrantType::Password => $this->password($params, $basic), - GrantType::DeviceCode => $this->deviceCode($params, $basic), - }; - } - - // ── grants ──────────────────────────────────────────────────────────────── - - private function authorizationCode(array $params, ?array $basic): array - { - $client = $this->authenticateClient($params, $basic, requireSecret: false); - - $rawCode = trim($params['code'] ?? ''); - if ($rawCode === '') { - throw OAuthException::invalidRequest('Missing authorization code.'); - } - - $code = $this->codes->findByHash(hash('sha256', $rawCode)); - if ($code === null || $code->isExpired()) { - throw OAuthException::invalidGrant('Authorization code is invalid or expired.'); - } - if (!hash_equals($code->clientId, $client->id)) { - throw OAuthException::invalidGrant('Authorization code was issued to another client.'); - } - - // Single-use: atomically consume. A losing race / replay → revoke any - // tokens already minted from it would be ideal; at minimum reject. - if (!$this->codes->consume($code->id)) { - throw OAuthException::invalidGrant('Authorization code has already been used.'); - } - - // redirect_uri must match the one bound at /authorize. - $redirectUri = trim($params['redirect_uri'] ?? ''); - if (!hash_equals($code->redirectUri, $redirectUri)) { - throw OAuthException::invalidGrant('redirect_uri mismatch.'); - } - - // PKCE verification. - if ($code->codeChallenge !== null) { - $verifier = trim($params['code_verifier'] ?? ''); - if ($verifier === '' || !Pkce::verify($verifier, $code->codeChallenge, (string) $code->codeChallengeMethod)) { - throw OAuthException::invalidGrant('PKCE verification failed.'); - } - } elseif ($client->isPublic()) { - throw OAuthException::invalidGrant('PKCE is required for public clients.'); - } - - return $this->issuePair($client, $code->userId, $code->scopes, nonce: $code->nonce); - } - - private function clientCredentials(array $params, ?array $basic): array - { - $client = $this->authenticateClient($params, $basic, requireSecret: true); - if (!$client->allowsGrant('client_credentials')) { - throw OAuthException::unauthorizedClient(); - } - - $scopes = $this->scopeValidator->validate($params['scope'] ?? '', $client); - - // No refresh token for client_credentials (RFC 6749 §4.4.3). Subject = client. - $access = $this->issuer->accessToken($client->id, $client->id, $scopes); - - return $this->response($access, null, $scopes); - } - - private function refreshToken(array $params, ?array $basic): array - { - $client = $this->authenticateClient($params, $basic, requireSecret: false); - - $raw = trim($params['refresh_token'] ?? ''); - if ($raw === '') { - throw OAuthException::invalidRequest('Missing refresh_token.'); - } - - $record = $this->refreshTokens->findByHash($this->issuer->hash($raw)); - if ($record === null) { - throw OAuthException::invalidGrant('Refresh token is invalid.'); - } - if (!hash_equals($record->clientId, $client->id)) { - throw OAuthException::invalidGrant('Refresh token was issued to another client.'); - } - - // Reuse detection: a presented-but-already-revoked token means replay — - // burn the whole family. - if ($record->revoked || $record->isExpired()) { - $this->refreshTokens->revokeFamily($record->familyId); - throw OAuthException::invalidGrant('Refresh token is expired or has been revoked.'); - } - if (!$this->refreshTokens->revokeIfActive($record->id)) { - $this->refreshTokens->revokeFamily($record->familyId); - throw OAuthException::invalidGrant('Refresh token reuse detected.'); - } - - // Narrowing scopes is allowed; widening is not. - $scopes = $record->scopes; - if (($params['scope'] ?? '') !== '') { - $requested = $this->scopeValidator->validate($params['scope'], $client); - foreach ($requested as $s) { - if (!in_array($s, $record->scopes, true)) { - throw OAuthException::invalidScope('Cannot widen scope on refresh.'); - } - } - $scopes = $requested; - } - - return $this->issuePair($client, $record->userId, $scopes, $record->familyId); - } - - private function password(array $params, ?array $basic): array - { - $client = $this->authenticateClient($params, $basic, requireSecret: true); - if (!$client->allowsGrant('password')) { - throw OAuthException::unauthorizedClient(); - } - if ($this->owners === null) { - throw OAuthException::unsupportedGrantType('Password grant is not configured.'); - } - - $userId = $this->owners->verify(trim($params['username'] ?? ''), (string) ($params['password'] ?? '')); - if ($userId === null) { - throw OAuthException::invalidGrant('Invalid resource owner credentials.'); - } - - $scopes = $this->scopeValidator->validate($params['scope'] ?? '', $client); - - return $this->issuePair($client, $userId, $scopes); - } - - /** - * Device Authorization Grant — the device polls with its device_code (RFC 8628 §3.4). - * Returns the standard polling errors (authorization_pending / slow_down / - * access_denied / expired_token) until the user approves. - */ - private function deviceCode(array $params, ?array $basic): array - { - if ($this->devices === null) { - throw OAuthException::unsupportedGrantType('Device grant is not enabled.'); - } - - $client = $this->authenticateClient($params, $basic, requireSecret: false); - - $raw = trim($params['device_code'] ?? ''); - if ($raw === '') { - throw OAuthException::invalidRequest('Missing device_code.'); - } - - $device = $this->devices->findByDeviceHash(hash('sha256', $raw)); - if ($device === null || !hash_equals($device->clientId, $client->id)) { - throw OAuthException::invalidGrant('Unknown device_code.'); - } - if ($device->isExpired()) { - throw new OAuthException('expired_token', 'The device code has expired.', 400); - } - if ($device->status === DeviceCode::DENIED) { - throw OAuthException::accessDenied('The user denied the request.'); - } - - if ($device->status === DeviceCode::PENDING) { - // Enforce the minimum poll interval — too-fast polling gets slow_down. - $now = new \DateTimeImmutable(); - if ($device->lastPolledAt !== null - && ($now->getTimestamp() - $device->lastPolledAt->getTimestamp()) < $device->interval) { - throw new OAuthException('slow_down', 'Polling too frequently.', 400); - } - $this->devices->markPolled($device->id, $now); - - throw new OAuthException('authorization_pending', 'The user has not yet approved the request.', 400); - } - - // Authorized — consume so the access token is issued exactly once. - if (!$this->devices->consume($device->id)) { - throw OAuthException::invalidGrant('Device code already redeemed.'); - } - - return $this->issuePair($client, (string) $device->userId, $device->scopes); - } - - // ── helpers ───────────────────────────────────────────────────────────── - - /** Issue an access+refresh pair (and an OIDC id_token when `openid` is granted). */ - private function issuePair(Client $client, string $userId, array $scopes, ?string $familyId = null, ?string $nonce = null): array - { - $access = $this->issuer->accessToken($userId, $client->id, $scopes); - $rawRefresh = $this->issuer->refreshToken(); - - $family = $familyId ?? bin2hex(random_bytes(16)); - $token = RefreshToken::of( - id: bin2hex(random_bytes(16)), - familyId: $family, - clientId: $client->id, - userId: $userId, - scopes: $scopes, - expiresAt: (new \DateTimeImmutable())->add(new \DateInterval('PT' . $this->refreshTtl . 'S')), - ); - $this->refreshTokens->store($token, $this->issuer->hash($rawRefresh)); - - // OpenID Connect: a granted `openid` scope yields an id_token. - $idToken = null; - if (in_array('openid', $scopes, true)) { - // A public client cannot verify an HS-signed id_token (no shared - // secret). Refuse rather than hand back an unverifiable token. - if ($client->isPublic() && !$this->issuer->isAsymmetric()) { - throw OAuthException::invalidRequest( - 'OpenID Connect for public clients requires asymmetric (RS/ES/PS) token signing.' - ); - } - $idToken = $this->issuer->idToken($userId, $client->id, $nonce); - } - - return $this->response($access, $rawRefresh, $scopes, $idToken); - } - - /** - * Authenticate the client. Confidential clients MUST present a valid secret; - * public clients are identified by client_id only (PKCE secures the flow). - */ - private function authenticateClient(array $params, ?array $basic, bool $requireSecret): Client - { - [$clientId, $clientSecret] = $basic ?? [trim($params['client_id'] ?? ''), $params['client_secret'] ?? null]; - - if ((string) $clientId === '') { - throw OAuthException::invalidClient('Missing client_id.'); - } - - $client = $this->clients->find((string) $clientId); - if ($client === null || $client->revoked) { - throw OAuthException::invalidClient(); - } - - if ($client->confidential) { - if ($clientSecret === null || $clientSecret === '' || $client->secretHash === null - || !$this->hasher->check((string) $clientSecret, $client->secretHash)) { - throw OAuthException::invalidClient('Invalid client credentials.'); - } - } elseif ($requireSecret) { - // A public client cannot satisfy a grant that demands client auth. - throw OAuthException::unauthorizedClient('This grant requires a confidential client.'); - } - - return $client; - } - - /** @return array */ - private function response(array $access, ?string $refresh, array $scopes, ?string $idToken = null): array - { - $body = [ - 'token_type' => 'Bearer', - 'access_token' => $access['token'], - 'expires_in' => $access['expires_in'], - 'scope' => implode(' ', $scopes), - ]; - if ($refresh !== null) { - $body['refresh_token'] = $refresh; - } - if ($idToken !== null) { - $body['id_token'] = $idToken; - } - - return $body; - } -} diff --git a/plugins/OAuth2/Domain/Entities/AuthCode.php b/plugins/OAuth2/Domain/Entities/AuthCode.php deleted file mode 100644 index 768139a..0000000 --- a/plugins/OAuth2/Domain/Entities/AuthCode.php +++ /dev/null @@ -1,55 +0,0 @@ - $scopes */ - public static function of( - string $id, - string $clientId, - string $userId, - string $redirectUri, - array $scopes, - ?string $codeChallenge, - ?string $codeChallengeMethod, - \DateTimeImmutable $expiresAt, - bool $consumed = false, - ?string $nonce = null, - ): self { - $c = (new self())->forceFill([ - 'id' => $id, - 'clientId' => $clientId, - 'userId' => $userId, - 'redirectUri' => $redirectUri, - 'scopes' => $scopes, - 'codeChallenge' => $codeChallenge, - 'codeChallengeMethod' => $codeChallengeMethod, - 'expiresAt' => $expiresAt, - 'consumed' => $consumed, - 'nonce' => $nonce, - ]); - $c->syncOriginal(); - - return $c; - } - - public function isExpired(?\DateTimeImmutable $now = null): bool - { - return $this->expiresAt <= ($now ?? new \DateTimeImmutable()); - } -} diff --git a/plugins/OAuth2/Domain/Entities/Client.php b/plugins/OAuth2/Domain/Entities/Client.php deleted file mode 100644 index 0513d14..0000000 --- a/plugins/OAuth2/Domain/Entities/Client.php +++ /dev/null @@ -1,105 +0,0 @@ - $redirectUris Exact-match allowed redirect targets. - * @param list $grantTypes Grant type strings this client may use. - * @param list $scopes Scopes this client may request (empty = any registered scope). - */ - public static function of( - string $id, - string $name, - ?string $secretHash, - array $redirectUris, - array $grantTypes, - array $scopes, - bool $confidential, - bool $revoked = false, - ?string $ownerId = null, - ): self { - $c = (new self())->forceFill([ - 'id' => $id, - 'name' => $name, - 'secretHash' => $secretHash, - 'redirectUris' => $redirectUris, - 'grantTypes' => $grantTypes, - 'scopes' => $scopes, - 'confidential' => $confidential, - 'revoked' => $revoked, - 'ownerId' => $ownerId, - ]); - $c->syncOriginal(); - - return $c; - } - - /** The user_id that registered this client, or null for a first-party client. */ - public function ownerId(): ?string - { - $owner = $this->ownerId ?? null; - - return is_string($owner) && $owner !== '' ? $owner : null; - } - - /** A secret-free public view for the self-service management API. */ - public function toPublicArray(): array - { - return [ - 'id' => (string) $this->id, - 'name' => (string) $this->name, - 'redirect_uris' => $this->redirectUris ?? [], - 'grant_types' => $this->grantTypes ?? [], - 'scopes' => $this->scopes ?? [], - 'confidential' => (bool) $this->confidential, - 'revoked' => (bool) $this->revoked, - ]; - } - - public function isPublic(): bool - { - return !$this->confidential || $this->secretHash === null; - } - - /** Exact-match redirect URI check (OAuth 2.1 — no wildcards). */ - public function allowsRedirect(string $uri): bool - { - foreach ($this->redirectUris as $allowed) { - if (hash_equals($allowed, $uri)) { - return true; - } - } - - return false; - } - - public function allowsGrant(string $grantType): bool - { - return in_array($grantType, $this->grantTypes, true); - } - - /** The first registered redirect URI (used when the request omits one and exactly one is registered). */ - public function defaultRedirect(): ?string - { - return $this->redirectUris[0] ?? null; - } -} diff --git a/plugins/OAuth2/Domain/Entities/DeviceCode.php b/plugins/OAuth2/Domain/Entities/DeviceCode.php deleted file mode 100644 index fd00384..0000000 --- a/plugins/OAuth2/Domain/Entities/DeviceCode.php +++ /dev/null @@ -1,57 +0,0 @@ - $scopes */ - public static function of( - string $id, - string $userCode, - string $clientId, - array $scopes, - string $status, - ?string $userId, - int $interval, - ?\DateTimeImmutable $lastPolledAt, - \DateTimeImmutable $expiresAt, - ): self { - $d = (new self())->forceFill([ - 'id' => $id, - 'userCode' => $userCode, - 'clientId' => $clientId, - 'scopes' => $scopes, - 'status' => $status, - 'userId' => $userId, - 'interval' => $interval, - 'lastPolledAt' => $lastPolledAt, - 'expiresAt' => $expiresAt, - ]); - $d->syncOriginal(); - - return $d; - } - - public function isExpired(?\DateTimeImmutable $now = null): bool - { - return $this->expiresAt <= ($now ?? new \DateTimeImmutable()); - } -} diff --git a/plugins/OAuth2/Domain/Entities/RefreshToken.php b/plugins/OAuth2/Domain/Entities/RefreshToken.php deleted file mode 100644 index 88de9b9..0000000 --- a/plugins/OAuth2/Domain/Entities/RefreshToken.php +++ /dev/null @@ -1,49 +0,0 @@ - $scopes */ - public static function of( - string $id, - string $familyId, - string $clientId, - string $userId, - array $scopes, - \DateTimeImmutable $expiresAt, - bool $revoked = false, - ): self { - $t = (new self())->forceFill([ - 'id' => $id, - 'familyId' => $familyId, - 'clientId' => $clientId, - 'userId' => $userId, - 'scopes' => $scopes, - 'expiresAt' => $expiresAt, - 'revoked' => $revoked, - ]); - $t->syncOriginal(); - - return $t; - } - - public function isExpired(?\DateTimeImmutable $now = null): bool - { - return $this->expiresAt <= ($now ?? new \DateTimeImmutable()); - } -} diff --git a/plugins/OAuth2/Domain/Exceptions/OAuthException.php b/plugins/OAuth2/Domain/Exceptions/OAuthException.php deleted file mode 100644 index 4647a8a..0000000 --- a/plugins/OAuth2/Domain/Exceptions/OAuthException.php +++ /dev/null @@ -1,70 +0,0 @@ - $this->error, 'error_description' => $this->getMessage()]; - } -} diff --git a/plugins/OAuth2/Domain/ValueObjects/GrantType.php b/plugins/OAuth2/Domain/ValueObjects/GrantType.php deleted file mode 100644 index 2cb2616..0000000 --- a/plugins/OAuth2/Domain/ValueObjects/GrantType.php +++ /dev/null @@ -1,25 +0,0 @@ - 128 || preg_match('/[^A-Za-z0-9\-._~]/', $verifier) === 1) { - return false; - } - - $computed = match ($method) { - self::METHOD_S256 => self::base64UrlEncode(hash('sha256', $verifier, true)), - self::METHOD_PLAIN => $verifier, - default => null, - }; - - return $computed !== null && hash_equals($challenge, $computed); - } - - private static function base64UrlEncode(string $raw): string - { - return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); - } -} diff --git a/plugins/OAuth2/Infrastructure/Cli/Concerns/TargetsTenant.php b/plugins/OAuth2/Infrastructure/Cli/Concerns/TargetsTenant.php deleted file mode 100644 index f7223c9..0000000 --- a/plugins/OAuth2/Infrastructure/Cli/Concerns/TargetsTenant.php +++ /dev/null @@ -1,59 +0,0 @@ -addOption( - 'tenant', - 't', - 'Target tenant (tenant_id, slug, or db name). Omit for the central/default connection.', - acceptsValue: true, - ); - $this->addOption('all', 'a', 'Apply across every active tenant database.'); - } - - /** The `--tenant` value, or null when none was given. */ - protected function tenantArg(): ?string - { - $value = trim((string) $this->option('tenant')); - - return $value === '' ? null : $value; - } - - /** - * The connection(s) this run targets: every active tenant with `--all`, else - * the named tenant (or central). Each entry is [label, DatabasePort]. - * - * @return list - */ - protected function tenantTargets(TenantConnections $connections): array - { - if ($this->hasOption('all')) { - return $connections->each(); - } - - return [[$this->tenantArg() ?? 'central', $connections->resolve($this->tenantArg())]]; - } - - /** Whether to print a per-target label (fleet runs, or more than one target). */ - protected function tenantLabelled(array $targets): bool - { - return count($targets) > 1 || $this->hasOption('all'); - } -} diff --git a/plugins/OAuth2/Infrastructure/Cli/CreateClientCommand.php b/plugins/OAuth2/Infrastructure/Cli/CreateClientCommand.php deleted file mode 100644 index 346dd60..0000000 --- a/plugins/OAuth2/Infrastructure/Cli/CreateClientCommand.php +++ /dev/null @@ -1,103 +0,0 @@ -name = 'oauth:client:create'; - $this->description = 'Register an OAuth2 client (confidential by default; --public for SPA/mobile)'; - - $this->addTenantOptions(); - $this->addOption('name', '', 'Display name', acceptsValue: true); - $this->addOption('public', '', 'Public client (no secret; must use PKCE)'); - $this->addOption('redirect', '', 'Allowed redirect URI (repeat comma-separated)', acceptsValue: true, default: ''); - $this->addOption('grant', '', 'Grant types, comma-separated', acceptsValue: true, default: 'authorization_code,refresh_token'); - $this->addOption('scope', '', 'Allowed scopes, space- or comma-separated', acceptsValue: true, default: ''); - } - - protected function handle(): int - { - $name = trim((string) $this->option('name')); - if ($name === '') { - $this->error('A --name is required.'); - return self::FAILURE; - } - - $public = $this->hasOption('public'); - $redirects = $this->splitList((string) $this->option('redirect')); - $grantTypes = $this->splitList((string) $this->option('grant')); - $scopes = $this->splitList(str_replace(' ', ',', (string) $this->option('scope'))); - - if (in_array('authorization_code', $grantTypes, true) && $redirects === []) { - $this->error('authorization_code requires at least one --redirect URI.'); - return self::FAILURE; - } - - $id = bin2hex(random_bytes(16)); - $secret = null; - $secretHash = null; - if (!$public) { - $secret = bin2hex(random_bytes(32)); - $secretHash = $this->hasher->make($secret); - } - - // One shared id/secret provisioned into each target (a single tenant, or - // the whole fleet with --all). - $targets = $this->tenantTargets($this->connections); - $labelled = $this->tenantLabelled($targets); - foreach ($targets as [$label, $db]) { - (new ClientRepository($db))->create($id, $name, $secretHash, $redirects, $grantTypes, $scopes, !$public); - if ($labelled) { - $this->info("· provisioned in {$label}"); - } - } - - $this->success('OAuth2 client created.'); - $this->info('client_id : ' . $id); - if ($secret !== null) { - $this->info('client_secret : ' . $secret . ' (shown once — store it now)'); - } else { - $this->info('type : public (PKCE required)'); - } - $this->info('grant_types : ' . implode(', ', $grantTypes)); - $this->info('redirect_uris : ' . (implode(', ', $redirects) ?: '(none)')); - - return self::SUCCESS; - } - - /** @return list */ - private function splitList(string $raw): array - { - return array_values(array_filter(array_map('trim', explode(',', $raw)), static fn (string $s) => $s !== '')); - } -} diff --git a/plugins/OAuth2/Infrastructure/Cli/ListClientsCommand.php b/plugins/OAuth2/Infrastructure/Cli/ListClientsCommand.php deleted file mode 100644 index a85be9d..0000000 --- a/plugins/OAuth2/Infrastructure/Cli/ListClientsCommand.php +++ /dev/null @@ -1,67 +0,0 @@ -name = 'oauth:client:list'; - $this->description = 'List registered OAuth2 clients'; - - $this->addTenantOptions(); - } - - protected function handle(): int - { - $targets = $this->tenantTargets($this->connections); - $labelled = $this->tenantLabelled($targets); - - foreach ($targets as [$label, $db]) { - if ($labelled) { - $this->info("── {$label} ──"); - } - - $clients = (new ClientRepository($db))->all(); - if ($clients === []) { - $this->info(' No OAuth2 clients registered.'); - continue; - } - - foreach ($clients as $c) { - $type = $c->confidential ? 'confidential' : 'public'; - $flag = $c->revoked ? ' [REVOKED]' : ''; - $this->info(sprintf( - '%s %-20s %-12s grants=%s%s', - $c->id, - $c->name, - $type, - implode(',', $c->grantTypes) ?: '-', - $flag, - )); - } - } - - return self::SUCCESS; - } -} diff --git a/plugins/OAuth2/Infrastructure/Cli/PruneCommand.php b/plugins/OAuth2/Infrastructure/Cli/PruneCommand.php deleted file mode 100644 index 11321a4..0000000 --- a/plugins/OAuth2/Infrastructure/Cli/PruneCommand.php +++ /dev/null @@ -1,73 +0,0 @@ -name = 'oauth:prune'; - $this->description = 'Delete expired OAuth2 authorization codes, refresh tokens and device codes'; - - $this->addTenantOptions(); - $this->addOption('watch', '', 'Run forever, pruning every N seconds', acceptsValue: true, default: ''); - } - - protected function handle(): int - { - $watch = (int) $this->option('watch'); - if ($watch <= 0) { - return $this->pruneAll(); - } - - $interval = max(60, $watch); - $this->info("Watching: pruning every {$interval}s. Ctrl-C to stop."); - while (true) { - $this->pruneAll(); - sleep($interval); - } - } - - private function pruneAll(): int - { - $targets = $this->hasOption('all') - ? $this->connections->each() - : [[$this->tenantArg() ?? 'central', $this->connections->resolve($this->tenantArg())]]; - - $labelled = count($targets) > 1 || $this->hasOption('all'); - - foreach ($targets as [$label, $db]) { - $codes = (new AuthCodeRepository($db))->deleteExpired(); - $tokens = (new RefreshTokenRepository($db))->deleteExpired(); - $devices = (new DeviceCodeRepository($db))->deleteExpired(); - - $prefix = $labelled ? "{$label}: " : ''; - $this->info("{$prefix}Pruned {$codes} auth code(s), {$tokens} refresh token(s), {$devices} device code(s)."); - } - - return self::SUCCESS; - } -} diff --git a/plugins/OAuth2/Infrastructure/Cli/RevokeClientCommand.php b/plugins/OAuth2/Infrastructure/Cli/RevokeClientCommand.php deleted file mode 100644 index 6f73b17..0000000 --- a/plugins/OAuth2/Infrastructure/Cli/RevokeClientCommand.php +++ /dev/null @@ -1,57 +0,0 @@ -name = 'oauth:client:revoke'; - $this->description = 'Revoke an OAuth2 client by id'; - - $this->addTenantOptions(); - $this->addOption('client', 'c', 'Client id to revoke', acceptsValue: true); - } - - protected function handle(): int - { - $id = trim((string) $this->option('client')); - if ($id === '') { - $this->error('Provide --client .'); - return self::FAILURE; - } - - $targets = $this->tenantTargets($this->connections); - $labelled = $this->tenantLabelled($targets); - $revoked = 0; - foreach ($targets as [$label, $db]) { - $ok = (new ClientRepository($db))->revoke($id); - $revoked += $ok ? 1 : 0; - if ($labelled) { - $this->info(($ok ? '✓ revoked in ' : '· not found in ') . $label); - } - } - - if ($revoked === 0) { - $this->error("Client not found: {$id}"); - return self::FAILURE; - } - - $this->success($labelled ? "Client {$id} revoked in {$revoked} database(s)." : "Client {$id} revoked."); - return self::SUCCESS; - } -} diff --git a/plugins/OAuth2/Infrastructure/Cli/RotateClientSecretCommand.php b/plugins/OAuth2/Infrastructure/Cli/RotateClientSecretCommand.php deleted file mode 100644 index 6b13044..0000000 --- a/plugins/OAuth2/Infrastructure/Cli/RotateClientSecretCommand.php +++ /dev/null @@ -1,67 +0,0 @@ -name = 'oauth:client:rotate'; - $this->description = 'Rotate a confidential OAuth2 client secret'; - - $this->addTenantOptions(); - $this->addOption('client', 'c', 'Client id', acceptsValue: true); - } - - protected function handle(): int - { - $id = trim((string) $this->option('client')); - if ($id === '') { - $this->error('Provide --client .'); - return self::FAILURE; - } - - // One shared new secret applied to each target that has the client. - $secret = bin2hex(random_bytes(32)); - $hash = $this->hasher->make($secret); - - $targets = $this->tenantTargets($this->connections); - $labelled = $this->tenantLabelled($targets); - $rotated = 0; - foreach ($targets as [$label, $db]) { - $ok = (new ClientRepository($db))->updateSecret($id, $hash); - $rotated += $ok ? 1 : 0; - if ($labelled) { - $this->info(($ok ? '✓ rotated in ' : '· no confidential client in ') . $label); - } - } - - if ($rotated === 0) { - $this->error("No confidential client found for id: {$id}"); - return self::FAILURE; - } - - $this->success('Secret rotated. The previous secret is now invalid.'); - $this->info('client_id : ' . $id); - $this->info('client_secret : ' . $secret . ' (shown once — store it now)'); - - return self::SUCCESS; - } -} diff --git a/plugins/OAuth2/Infrastructure/Cli/TenantConnections.php b/plugins/OAuth2/Infrastructure/Cli/TenantConnections.php deleted file mode 100644 index 63351a1..0000000 --- a/plugins/OAuth2/Infrastructure/Cli/TenantConnections.php +++ /dev/null @@ -1,106 +0,0 @@ -` to target one, or omit it to use the - * central/default connection (the historical behaviour). - * - * Tenancy is OPTIONAL. When the plugin is absent, `registry`/`resolver` are null - * and only the central connection is available — passing `--tenant` then fails - * with a clear message instead of silently hitting the wrong database. The - * Tenancy type references below are only reached when tenancy IS available, so - * this class loads fine without the Tenancy plugin on disk. - */ -final class TenantConnections -{ - public function __construct( - private readonly DatabasePort $central, - private readonly ?TenantRegistryContract $registry = null, - private readonly ?TenantConnectionResolverContract $resolver = null, - ) { - } - - public function tenancyAvailable(): bool - { - return $this->registry !== null && $this->resolver !== null; - } - - /** The central / default connection (no tenant routing). */ - public function central(): DatabasePort - { - return $this->central; - } - - /** Resolve the connection for a tenant identifier (tenant_id, slug, or db name). */ - public function for(string $identifier): DatabasePort - { - if (!$this->tenancyAvailable()) { - throw new \RuntimeException( - '--tenant needs the Tenancy plugin, which is not enabled for this project.', - ); - } - - return $this->resolver->for($this->tenantId($identifier)); - } - - /** - * Resolve the DatabasePort for a command run: the named tenant, or central - * when no `--tenant` was given. - */ - public function resolve(?string $identifier): DatabasePort - { - return $identifier === null ? $this->central : $this->for($identifier); - } - - /** - * Every active tenant's connection (for fleet-wide `--all` operations), each - * labelled "slug (tenant_id)". Falls back to a single central entry when - * Tenancy is not available. Unreachable tenants are skipped. - * - * @return list - */ - public function each(): array - { - if (!$this->tenancyAvailable()) { - return [['central', $this->central]]; - } - - $out = []; - foreach ($this->registry->listByStatus(TenantStatus::Active->value) as $tenant) { - try { - $out[] = ["{$tenant->slug} ({$tenant->tenantId})", $this->resolver->for($tenant->tenantId)]; - } catch (\Throwable) { - // Skip a suspended / unreachable tenant — one bad DB never aborts the fleet. - } - } - - return $out; - } - - /** Map a tenant_id | slug | db name to its tenant_id (or throw). */ - private function tenantId(string $identifier): string - { - foreach ($this->registry->listByStatus(TenantStatus::Active->value) as $tenant) { - if ($tenant->tenantId === $identifier - || $tenant->slug === $identifier - || $tenant->dbName === $identifier) { - return $tenant->tenantId; - } - } - - throw new \RuntimeException("Unknown or inactive tenant: {$identifier}"); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Concerns/ChecksOAuthAdmin.php b/plugins/OAuth2/Infrastructure/Http/Concerns/ChecksOAuthAdmin.php deleted file mode 100644 index e96db6d..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Concerns/ChecksOAuthAdmin.php +++ /dev/null @@ -1,37 +0,0 @@ -isGuest()) { - return false; - } - - $role = (string) (env('OAUTH_ADMIN_ROLE') ?: 'admin'); - if ($role !== '' && $identity->hasRole($role)) { - return true; - } - - foreach (explode(',', (string) env('OAUTH_ADMIN_USERS')) as $allowed) { - $allowed = trim($allowed); - if ($allowed !== '' && $allowed === $identity->userId) { - return true; - } - } - - return false; - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Concerns/SpeaksOAuth.php b/plugins/OAuth2/Infrastructure/Http/Concerns/SpeaksOAuth.php deleted file mode 100644 index 575b23f..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Concerns/SpeaksOAuth.php +++ /dev/null @@ -1,57 +0,0 @@ -toArray(), $e->status); - - // RFC 6749 §5.2 — invalid_client over Basic auth must include WWW-Authenticate. - if ($e->error === 'invalid_client') { - $response = $response->withHeader('WWW-Authenticate', 'Basic realm="oauth"'); - } - - return $response->withHeader('Cache-Control', 'no-store')->withHeader('Pragma', 'no-cache'); - } - - protected function noStore(Response $response): Response - { - return $response->withHeader('Cache-Control', 'no-store')->withHeader('Pragma', 'no-cache'); - } - - /** - * Extract [client_id, client_secret] from an HTTP Basic Authorization header. - * - * @return array{0:string,1:string}|null - */ - protected function basicClient(Request $request): ?array - { - $header = $request->header('Authorization') ?? ''; - if (!str_starts_with($header, 'Basic ')) { - return null; - } - - $decoded = base64_decode(trim(substr($header, 6)), true); - if ($decoded === false || !str_contains($decoded, ':')) { - return null; - } - - [$id, $secret] = explode(':', $decoded, 2); - - // Credentials are form-urlencoded inside Basic per RFC 6749 §2.3.1. - return [urldecode($id), urldecode($secret)]; - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/AdminController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/AdminController.php deleted file mode 100644 index f7470b8..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/AdminController.php +++ /dev/null @@ -1,329 +0,0 @@ -guard()) { - return $deny; - } - - $all = $this->clients->all(); - $owners = $this->resolveOwners($all); - - $clients = array_map( - static function (Client $c) use ($owners): array { - $ownerId = $c->ownerId(); - - return $c->toPublicArray() + [ - 'owner_id' => $ownerId, - 'owner' => $ownerId !== null && $ownerId !== '' ? ($owners[$ownerId] ?? ['id' => $ownerId]) : null, - ]; - }, - $all, - ); - - return $this->ok(['clients' => $clients]); - } - - public function createClient(): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - $name = trim((string) $this->request?->input('name', '')); - if ($name === '') { - return $this->unprocessable(['name' => 'A client name is required.']); - } - - $redirects = $this->list($this->request?->input('redirect_uris', [])); - $scopes = $this->list($this->request?->input('scopes', [])); - $public = $this->request?->boolean('public') ?? false; - - $grants = $this->list($this->request?->input('grant_types', [])); - if ($grants === []) { - $grants = ['authorization_code', 'refresh_token']; - } - - // Grant types must be ones this server supports (RFC 6749 + RFC 8628). - $badGrants = array_values(array_filter($grants, static fn(string $g) => GrantType::tryFromString($g) === null)); - if ($badGrants !== []) { - return $this->unprocessable(['grant_types' => 'Unsupported grant type(s): ' . implode(', ', $badGrants) . '.']); - } - if (in_array('authorization_code', $grants, true) && $redirects === []) { - return $this->unprocessable(['redirect_uris' => 'authorization_code requires at least one redirect URI.']); - } - - // Every requested scope must exist in the catalogue (add them in Scopes first). - $missing = $this->missingScopes($scopes); - if ($missing !== []) { - return $this->unprocessable(['scopes' => 'Unknown scope(s): ' . implode(', ', $missing) . '. Register them under Scopes first.']); - } - - $id = bin2hex(random_bytes(16)); - $secret = null; - $secretHash = null; - if (!$public) { - $secret = bin2hex(random_bytes(32)); - $secretHash = $this->hasher->make($secret); - } - - $this->clients->create($id, $name, $secretHash, $redirects, $grants, $scopes, !$public, $this->identity()->userId); - - return $this->created(array_filter([ - 'id' => $id, - 'name' => $name, - 'client_secret' => $secret, - 'redirect_uris' => $redirects, - 'grant_types' => $grants, - 'scopes' => $scopes, - 'confidential' => !$public, - ], static fn($v) => $v !== null)); - } - - public function updateClient(string $id): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - $client = $this->clients->find($id); - if ($client === null) { - return Response::notFound(); - } - - $name = trim((string) $this->request?->input('name', $client->name)); - if ($name === '') { - return $this->unprocessable(['name' => 'A client name is required.']); - } - - $redirects = $this->list($this->request?->input('redirect_uris', $client->redirectUris ?? [])); - $scopes = $this->list($this->request?->input('scopes', $client->scopes ?? [])); - - $missing = $this->missingScopes($scopes); - if ($missing !== []) { - return $this->unprocessable(['scopes' => 'Unknown scope(s): ' . implode(', ', $missing) . '.']); - } - - $this->clients->updateDetails($id, $name, $redirects, $scopes); - - return $this->ok($this->clients->find($id)?->toPublicArray() ?? []); - } - - public function rotateClient(string $id): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - $secret = bin2hex(random_bytes(32)); - if (!$this->clients->updateSecret($id, $this->hasher->make($secret))) { - return $this->unprocessable(['id' => 'No confidential client found for this id.']); - } - - return $this->ok(['id' => $id, 'client_secret' => $secret]); - } - - public function revokeClient(string $id): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - return $this->clients->revoke($id) ? $this->noContent() : Response::notFound(); - } - - // ── scopes ─────────────────────────────────────────────────────────────── - - public function scopes(): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - $scopes = []; - foreach ($this->scopes->describe() as $id => $description) { - $scopes[] = ['id' => $id, 'description' => $description]; - } - - return $this->ok(['scopes' => $scopes]); - } - - public function createScope(): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - $id = trim((string) $this->request?->input('id', '')); - if ($id === '' || preg_match('/^[A-Za-z0-9_:.\-]+$/', $id) !== 1) { - return $this->unprocessable(['id' => 'A valid scope id is required (letters, digits, _ : . -).']); - } - - $this->scopes->put($id, trim((string) $this->request?->input('description', ''))); - - return $this->created(['id' => $id]); - } - - public function deleteScope(string $id): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - return $this->scopes->delete($id) ? $this->noContent() : Response::notFound(); - } - - // ── authorized tokens (all users) ──────────────────────────────────────── - - public function authorizedTokens(): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - $tokens = array_map(static fn($t): array => [ - 'id' => (string) $t->id, - 'client_id' => (string) $t->clientId, - 'user_id' => (string) $t->userId, - 'scopes' => $t->scopes ?? [], - 'expires_at' => $t->expiresAt->format(\DateTimeInterface::RFC3339), - ], $this->refreshTokens->allActive()); - - return $this->ok(['authorized_tokens' => $tokens]); - } - - public function revokeToken(string $id): Response - { - if ($deny = $this->guard()) { - return $deny; - } - - foreach ($this->refreshTokens->allActive() as $token) { - if ((string) $token->id === $id) { - $this->refreshTokens->revokeFamily((string) $token->familyId); - - return $this->noContent(); - } - } - - return Response::notFound(); - } - - // ── access gate ────────────────────────────────────────────────────────── - - /** Returns a deny Response when the caller is not an authenticated admin. */ - private function guard(): ?Response - { - $identity = $this->identity(); - if ($identity->isGuest()) { - return Response::unauthorized('Authentication required.'); - } - - return $this->isOAuthAdmin($identity) ? null : Response::forbidden('Administrator access required.'); - } - - /** @return list */ - private function list(mixed $value): array - { - return is_array($value) ? array_values(array_filter($value, 'is_string')) : []; - } - - /** - * Scopes from the given list that are NOT registered in the catalogue. - * - * @param list $scopes - * @return list - */ - private function missingScopes(array $scopes): array - { - return array_values(array_filter($scopes, fn(string $s): bool => !$this->scopes->exists($s))); - } - - /** - * Resolve each client owner's display profile (avatar / full name / email) - * from the central user store — deduped and best-effort (a deleted or - * unresolvable owner falls back to just its id). - * - * @param list $clients - * @return array - */ - private function resolveOwners(array $clients): array - { - $ids = []; - foreach ($clients as $c) { - $id = $c->ownerId(); - if ($id !== null && $id !== '') { - $ids[$id] = true; - } - } - - $map = []; - foreach (array_keys($ids) as $id) { - try { - $user = $this->users->find($id); - } catch (\Throwable) { - $user = null; - } - - $map[$id] = $user === null - ? ['id' => $id] - : [ - 'id' => $user->id, - 'username' => $user->username, - 'email' => $user->email, - 'full_name' => $user->fullName, - 'avatar_url' => $user->avatarUrl, - ]; - } - - return $map; - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/AdminUiController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/AdminUiController.php deleted file mode 100644 index 7a492b1..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/AdminUiController.php +++ /dev/null @@ -1,64 +0,0 @@ -identity(); - - if ($identity === null || $identity->isGuest()) { - return Response::redirect('/login?return=' . urlencode('/oauth/admin/simulate')); - } - - if (!$this->isOAuthAdmin($identity)) { - return Response::forbidden('Administrator access required.'); - } - - return $this->pageflow->render($request, 'OAuth2/Simulate', 'admin', []); - } - - public function dashboard(Request $request): Response - { - $identity = $request->identity(); - - if ($identity === null || $identity->isGuest()) { - return Response::redirect('/login?return=' . urlencode('/oauth/admin')); - } - - if (!$this->isOAuthAdmin($identity)) { - return Response::forbidden('Administrator access required.'); - } - - return $this->pageflow->render($request, 'OAuth2/Admin', 'admin', [ - 'userId' => $identity->userId, - 'email' => $identity->email, - ]); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/AuthorizationController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/AuthorizationController.php deleted file mode 100644 index ae40d06..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/AuthorizationController.php +++ /dev/null @@ -1,119 +0,0 @@ -resolveRequest(); - - try { - $req = $this->authz->validate($request->queryAll()); - } catch (OAuthException $e) { - return $this->renderError($e); - } - - $identity = $request->identity(); - if ($identity === null || $identity->isGuest()) { - // Not logged in — send to login, returning here afterwards. Auth's login - // honours `redirectTo` (not `return`) and its open-redirect guard accepts - // only a RELATIVE path, so hand it path+query, never the absolute URL. - $query = $request->uri()->getQuery(); - $target = $request->path() . ($query !== '' ? '?' . $query : ''); - - return $this->redirect('/login?redirectTo=' . urlencode($target)); - } - - // Store the validated request SERVER-SIDE and hand the form only an opaque - // reference. The client/redirect/scope/PKCE-challenge are never round-tripped - // through the browser, so the consent POST cannot tamper with them. - $authzId = bin2hex(random_bytes(16)); - $this->session->put(self::SESSION_PREFIX . $authzId, $req->toFormState()); - - return $this->pageflow->render($request, 'OAuth2/Consent', 'admin', [ - 'csrf' => $this->_csrfToken(), - 'clientName' => (string) $req->client->name, - 'scopes' => array_values($req->scopes), - 'authzId' => $authzId, - ]); - } - - public function decision(): Response - { - $request = $this->resolveRequest(); - $identity = $request->identity(); - if ($identity === null || $identity->isGuest()) { - return Response::unauthorized('Login required.'); - } - - // Pull the stored request by its opaque id (single-use — removed on read). - $authzId = (string) $request->input('authz_id'); - $stored = $authzId !== '' ? $this->session->pull(self::SESSION_PREFIX . $authzId) : null; - if (!is_array($stored) || $stored === []) { - return $this->renderError(OAuthException::invalidRequest('The authorization request expired. Please try again.')); - } - - try { - // Re-validate the server-stored parameters (client/redirect/scope/PKCE). - $req = $this->authz->validate($stored); - } catch (OAuthException $e) { - return $this->renderError($e); - } - - $approved = in_array($request->input('action'), ['approve', 'allow'], true) - || $request->boolean('approve'); - - if (!$approved) { - return $this->redirect($this->authz->buildRedirect($req->redirectUri, [ - 'error' => 'access_denied', - 'state' => $req->state, - ])); - } - - return $this->redirect($this->authz->issueCode($req, $identity->userId)); - } - - /** - * Hard errors (bad client / redirect_uri) cannot be redirected — show them - * directly. We never redirect to an unverified redirect_uri. - */ - private function renderError(OAuthException $e): Response - { - return Response::json($e->toArray(), $e->status); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/AuthorizedTokenController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/AuthorizedTokenController.php deleted file mode 100644 index 898939e..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/AuthorizedTokenController.php +++ /dev/null @@ -1,68 +0,0 @@ -requireUser(); - if ($userId === null) { - return Response::unauthorized('Authentication required.'); - } - - $tokens = array_map(static fn($t) => [ - 'id' => (string) $t->id, - 'client_id' => (string) $t->clientId, - 'scopes' => $t->scopes ?? [], - 'expires_at' => $t->expiresAt->format(\DateTimeInterface::RFC3339), - ], $this->refreshTokens->findByUser($userId)); - - return $this->ok(['authorized_tokens' => $tokens]); - } - - public function destroy(string $id): Response - { - $userId = $this->requireUser(); - if ($userId === null) { - return Response::unauthorized('Authentication required.'); - } - - // Only revoke a grant that belongs to the caller. - foreach ($this->refreshTokens->findByUser($userId) as $token) { - if ((string) $token->id === $id) { - $this->refreshTokens->revokeFamily((string) $token->familyId); - - return $this->noContent(); - } - } - - return Response::notFound(); - } - - private function requireUser(): ?string - { - $identity = $this->identity(); - - return $identity->isGuest() ? null : $identity->userId; - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/ClientController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/ClientController.php deleted file mode 100644 index 2580e75..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/ClientController.php +++ /dev/null @@ -1,141 +0,0 @@ -requireUser(); - if ($userId === null) { - return Response::unauthorized('Authentication required.'); - } - - $clients = array_map( - static fn($c) => $c->toPublicArray(), - $this->clients->findByOwner($userId), - ); - - return $this->ok(['clients' => $clients]); - } - - public function store(): Response - { - $userId = $this->requireUser(); - if ($userId === null) { - return Response::unauthorized('Authentication required.'); - } - - $name = trim((string) $this->request?->input('name', '')); - if ($name === '') { - return $this->unprocessable(['name' => 'A client name is required.']); - } - - $redirects = $this->list($this->request?->input('redirect_uris', [])); - $scopes = $this->list($this->request?->input('scopes', [])); - $public = $this->request?->boolean('public') ?? false; - - $id = bin2hex(random_bytes(16)); - $secret = null; - $secretHash = null; - if (!$public) { - $secret = bin2hex(random_bytes(32)); - $secretHash = $this->hasher->make($secret); - } - - $grants = $public ? ['authorization_code', 'refresh_token'] : self::DEFAULT_GRANTS; - - $this->clients->create($id, $name, $secretHash, $redirects, $grants, $scopes, !$public, $userId); - - // client_secret is returned exactly once. - return $this->created(array_filter([ - 'id' => $id, - 'name' => $name, - 'client_secret' => $secret, - 'redirect_uris' => $redirects, - 'scopes' => $scopes, - 'confidential' => !$public, - ], static fn($v) => $v !== null)); - } - - public function update(string $id): Response - { - $userId = $this->requireUser(); - if ($userId === null) { - return Response::unauthorized('Authentication required.'); - } - - $client = $this->clients->find($id); - if ($client === null || $client->ownerId() !== $userId) { - return Response::notFound(); - } - - $name = trim((string) $this->request?->input('name', $client->name)); - if ($name === '') { - return $this->unprocessable(['name' => 'A client name is required.']); - } - - $redirects = $this->list($this->request?->input('redirect_uris', $client->redirectUris ?? [])); - $scopes = $this->list($this->request?->input('scopes', $client->scopes ?? [])); - - $this->clients->updateDetails($id, $name, $redirects, $scopes); - - return $this->ok($this->clients->find($id)?->toPublicArray() ?? []); - } - - public function destroy(string $id): Response - { - $userId = $this->requireUser(); - if ($userId === null) { - return Response::unauthorized('Authentication required.'); - } - - $client = $this->clients->find($id); - if ($client === null || $client->ownerId() !== $userId) { - return Response::notFound(); - } - - $this->clients->revoke($id); - - return $this->noContent(); - } - - private function requireUser(): ?string - { - $identity = $this->identity(); - - return $identity->isGuest() ? null : $identity->userId; - } - - /** @return list */ - private function list(mixed $value): array - { - return is_array($value) ? array_values(array_filter($value, 'is_string')) : []; - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/DeviceController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/DeviceController.php deleted file mode 100644 index c36734e..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/DeviceController.php +++ /dev/null @@ -1,41 +0,0 @@ -resolveRequest(); - - try { - $result = $this->devices->authorize($request->all(), $this->basicClient($request)); - } catch (OAuthException $e) { - return $this->oauthError($e); - } - - $verify = (string) $request->site()->to('oauth/device'); - $result['verification_uri'] = $verify; - $result['verification_uri_complete'] = $verify . '?user_code=' . urlencode($result['user_code']); - - return $this->noStore(Response::json($result)); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/DeviceVerificationController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/DeviceVerificationController.php deleted file mode 100644 index 41a495e..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/DeviceVerificationController.php +++ /dev/null @@ -1,78 +0,0 @@ -resolveRequest(); - $identity = $request->identity(); - if ($identity === null || $identity->isGuest()) { - return $this->redirect('/login?return=' . urlencode((string) $request->uri())); - } - - return $this->view('oauth2::device', [ - 'csrf' => $this->_csrfToken(), - 'userCode' => (string) $request->query('user_code'), - 'message' => '', - ]); - } - - public function submit(): Response - { - $request = $this->resolveRequest(); - $identity = $request->identity(); - if ($identity === null || $identity->isGuest()) { - return Response::unauthorized('Login required.'); - } - - $userCode = strtoupper(trim((string) $request->input('user_code'))); - $device = $userCode === '' ? null : $this->devices->findByUserCode($userCode); - - if ($device === null || $device->isExpired() || $device->status !== DeviceCode::PENDING) { - return $this->view('oauth2::device', [ - 'csrf' => $this->_csrfToken(), - 'userCode' => $userCode, - 'message' => 'That code is invalid, expired, or already used.', - ], status: 422); - } - - $approved = in_array($request->input('action'), ['approve', 'allow'], true); - if ($approved) { - $this->devices->authorize($device->id, $identity->userId); - $msg = 'Device approved. You can return to your device.'; - } else { - $this->devices->deny($device->id); - $msg = 'Device denied.'; - } - - return $this->view('oauth2::device', [ - 'csrf' => $this->_csrfToken(), - 'userCode' => '', - 'message' => $msg, - ]); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/DiscoveryController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/DiscoveryController.php deleted file mode 100644 index 03d1d99..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/DiscoveryController.php +++ /dev/null @@ -1,70 +0,0 @@ -resolveRequest(); - $base = $request->site(); - - return Response::json([ - 'issuer' => (string) $base->to('/'), - 'authorization_endpoint' => (string) $base->to('oauth/authorize'), - 'token_endpoint' => (string) $base->to('oauth/token'), - 'introspection_endpoint' => (string) $base->to('oauth/introspect'), - 'revocation_endpoint' => (string) $base->to('oauth/revoke'), - 'device_authorization_endpoint' => (string) $base->to('oauth/device_authorization'), - 'jwks_uri' => (string) $base->to('oauth/jwks'), - 'grant_types_supported' => [ - 'authorization_code', 'client_credentials', 'refresh_token', 'password', - 'urn:ietf:params:oauth:grant-type:device_code', - ], - 'response_types_supported' => ['code'], - 'code_challenge_methods_supported' => ['S256', 'plain'], - 'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], - 'scopes_supported' => $this->scopes->all(), - ])->withHeader('Cache-Control', 'public, max-age=3600'); - } - - /** GET /.well-known/openid-configuration — OIDC discovery (OIDC Discovery §3). */ - public function openidConfiguration(): Response - { - $base = $this->resolveRequest()->site(); - $algo = env('JWT_ALGO') ?: 'HS256'; - - return Response::json([ - 'issuer' => (string) $base->to('/'), - 'authorization_endpoint' => (string) $base->to('oauth/authorize'), - 'token_endpoint' => (string) $base->to('oauth/token'), - 'userinfo_endpoint' => (string) $base->to('oauth/userinfo'), - 'device_authorization_endpoint' => (string) $base->to('oauth/device_authorization'), - 'jwks_uri' => (string) $base->to('oauth/jwks'), - 'response_types_supported' => ['code'], - 'subject_types_supported' => ['public'], - 'id_token_signing_alg_values_supported' => [$algo], - 'grant_types_supported' => [ - 'authorization_code', 'client_credentials', 'refresh_token', 'password', - 'urn:ietf:params:oauth:grant-type:device_code', - ], - 'scopes_supported' => array_values(array_unique(['openid', ...$this->scopes->all()])), - 'code_challenge_methods_supported' => ['S256', 'plain'], - 'token_endpoint_auth_methods_supported' => ['client_secret_basic', 'client_secret_post', 'none'], - ])->withHeader('Cache-Control', 'public, max-age=3600'); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/IntrospectionController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/IntrospectionController.php deleted file mode 100644 index fac6e1f..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/IntrospectionController.php +++ /dev/null @@ -1,73 +0,0 @@ -resolveRequest(); - try { - $this->authenticateClient($request); - } catch (OAuthException $e) { - return $this->oauthError($e); - } - - $result = $this->introspection->introspect(trim((string) $request->input('token'))); - - return $this->noStore(Response::json($result)); - } - - public function revoke(): Response - { - $request = $this->resolveRequest(); - try { - $this->authenticateClient($request); - } catch (OAuthException $e) { - return $this->oauthError($e); - } - - $this->introspection->revoke(trim((string) $request->input('token'))); - - // RFC 7009 §2.2 — success regardless of whether the token existed. - return $this->noStore(Response::json(['ok' => true])); - } - - private function authenticateClient(Request $request): void - { - $basic = $this->basicClient($request); - [$clientId, $secret] = $basic ?? [trim((string) $request->input('client_id')), (string) $request->input('client_secret')]; - - $client = $clientId === '' ? null : $this->clients->find($clientId); - if ($client === null || $client->revoked || $client->secretHash === null - || !$this->hasher->check((string) $secret, $client->secretHash)) { - throw OAuthException::invalidClient(); - } - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/JwksController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/JwksController.php deleted file mode 100644 index 1ea20c8..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/JwksController.php +++ /dev/null @@ -1,101 +0,0 @@ -publicKey !== null ? $this->toJwk($this->publicKey) : null; - if ($jwk !== null) { - $keys[] = $jwk; - } - - return Response::json(['keys' => $keys])->withHeader('Cache-Control', 'public, max-age=3600'); - } - - /** Convert an RSA public-key PEM to a JWK (RFC 7518 §6.3.1). */ - private function toJwk(string $pem): ?array - { - if (!function_exists('openssl_pkey_get_public')) { - return null; - } - - $resource = @openssl_pkey_get_public($pem); - if ($resource === false) { - return null; - } - - $details = openssl_pkey_get_details($resource); - if ($details === false) { - return null; - } - - $kid = ($this->keyId !== null && $this->keyId !== '') ? $this->keyId : null; - - // RSA (RS*/PS*). - if (isset($details['rsa']['n'], $details['rsa']['e'])) { - return array_filter([ - 'kty' => 'RSA', - 'use' => 'sig', - 'alg' => $this->algo, - 'kid' => $kid, - 'n' => $this->base64Url($details['rsa']['n']), - 'e' => $this->base64Url($details['rsa']['e']), - ], static fn ($v) => $v !== null); - } - - // EC (ES*). - if (isset($details['ec']['x'], $details['ec']['y'], $details['ec']['curve_name'])) { - $crv = self::CURVES[$details['ec']['curve_name']] ?? null; - if ($crv === null) { - return null; - } - - return array_filter([ - 'kty' => 'EC', - 'use' => 'sig', - 'alg' => $this->algo, - 'kid' => $kid, - 'crv' => $crv, - 'x' => $this->base64Url($details['ec']['x']), - 'y' => $this->base64Url($details['ec']['y']), - ], static fn ($v) => $v !== null); - } - - return null; - } - - /** OpenSSL curve name → JWK `crv` (RFC 7518 §6.2.1.1). */ - private const CURVES = [ - 'prime256v1' => 'P-256', - 'secp256r1' => 'P-256', - 'secp384r1' => 'P-384', - 'secp521r1' => 'P-521', - ]; - - private function base64Url(string $raw): string - { - return rtrim(strtr(base64_encode($raw), '+/', '-_'), '='); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/ScopeController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/ScopeController.php deleted file mode 100644 index 4509428..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/ScopeController.php +++ /dev/null @@ -1,26 +0,0 @@ -ok(['scopes' => $this->scopes->scopes()]); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/TokenController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/TokenController.php deleted file mode 100644 index a92d4a9..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/TokenController.php +++ /dev/null @@ -1,40 +0,0 @@ -resolveRequest(); - - try { - $body = $this->tokens->handle($request->all(), $this->basicClient($request)); - } catch (OAuthException $e) { - return $this->oauthError($e); - } - - return $this->noStore(Response::json($body)); - } -} diff --git a/plugins/OAuth2/Infrastructure/Http/Controllers/UserInfoController.php b/plugins/OAuth2/Infrastructure/Http/Controllers/UserInfoController.php deleted file mode 100644 index 534ef51..0000000 --- a/plugins/OAuth2/Infrastructure/Http/Controllers/UserInfoController.php +++ /dev/null @@ -1,53 +0,0 @@ -identity(); - if ($identity->isGuest()) { - return Response::unauthorized('A valid access token is required.') - ->withHeader('WWW-Authenticate', 'Bearer'); - } - - // Scopes are published into Identity.permissions namespaced as `scope:*`. - if (!$identity->hasPermission('scope:openid')) { - return Response::forbidden('The openid scope is required.'); - } - - // Strip the `scope:` prefix back to bare scope names for the provider. - $scopes = []; - foreach ($identity->permissions as $perm) { - if (str_starts_with($perm, 'scope:')) { - $scopes[] = substr($perm, 6); - } - } - - return $this->noStore(Response::json( - $this->userInfo->claims($identity->userId, $scopes), - )); - } - - private function noStore(Response $response): Response - { - return $response->withHeader('Cache-Control', 'no-store')->withHeader('Pragma', 'no-cache'); - } -} diff --git a/plugins/OAuth2/Infrastructure/Identity/SubjectUserInfoProvider.php b/plugins/OAuth2/Infrastructure/Identity/SubjectUserInfoProvider.php deleted file mode 100644 index 067d68f..0000000 --- a/plugins/OAuth2/Infrastructure/Identity/SubjectUserInfoProvider.php +++ /dev/null @@ -1,20 +0,0 @@ - $userId]; - } -} diff --git a/plugins/OAuth2/Infrastructure/Identity/UserResourceOwnerVerifier.php b/plugins/OAuth2/Infrastructure/Identity/UserResourceOwnerVerifier.php deleted file mode 100644 index 20303f4..0000000 --- a/plugins/OAuth2/Infrastructure/Identity/UserResourceOwnerVerifier.php +++ /dev/null @@ -1,28 +0,0 @@ -users->verifyCredentials($username, $password)?->id; - } -} diff --git a/plugins/OAuth2/Infrastructure/Persistence/AuthCodeRepository.php b/plugins/OAuth2/Infrastructure/Persistence/AuthCodeRepository.php deleted file mode 100644 index 16ae012..0000000 --- a/plugins/OAuth2/Infrastructure/Persistence/AuthCodeRepository.php +++ /dev/null @@ -1,102 +0,0 @@ -db->execute( - 'INSERT INTO oauth_auth_codes - (id, code_hash, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, nonce, consumed, expires_at, created_at) - VALUES - (:id, :hash, :client, :user, :redirect, :scopes, :challenge, :method, :nonce, 0, :expires, :created)', - [ - 'id' => $code->id, - 'hash' => $codeHash, - 'client' => $code->clientId, - 'user' => $code->userId, - 'redirect' => $code->redirectUri, - 'scopes' => json_encode(array_values($code->scopes)), - 'challenge' => $code->codeChallenge, - 'method' => $code->codeChallengeMethod, - 'nonce' => $code->nonce, - 'expires' => $code->expiresAt->format('Y-m-d H:i:s'), - 'created' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to store authorization code', layer: 'repository.oauth', previous: $e); - } - } - - public function findByHash(string $codeHash): ?AuthCode - { - try { - $row = $this->db->queryOne( - 'SELECT * FROM oauth_auth_codes WHERE code_hash = :hash', - ['hash' => $codeHash], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to load authorization code', layer: 'repository.oauth', previous: $e); - } - - if ($row === null) { - return null; - } - - $scopes = json_decode((string) ($row['scopes'] ?? '[]'), true); - - return AuthCode::of( - id: (string) $row['id'], - clientId: (string) $row['client_id'], - userId: (string) $row['user_id'], - redirectUri: (string) $row['redirect_uri'], - scopes: is_array($scopes) ? array_values(array_filter($scopes, 'is_string')) : [], - codeChallenge: ($row['code_challenge'] ?? null) ?: null, - codeChallengeMethod: ($row['code_challenge_method'] ?? null) ?: null, - expiresAt: new \DateTimeImmutable((string) $row['expires_at']), - consumed: (bool) $row['consumed'], - nonce: ($row['nonce'] ?? null) ?: null, - ); - } - - public function consume(string $codeId): bool - { - try { - // Atomic single-use: only the first caller flips consumed 0→1. - return $this->db->execute( - 'UPDATE oauth_auth_codes SET consumed = 1 WHERE id = :id AND consumed = 0', - ['id' => $codeId], - ) === 1; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to consume authorization code', layer: 'repository.oauth', previous: $e); - } - } - - public function deleteExpired(?\DateTimeImmutable $now = null): int - { - $cutoff = ($now ?? new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - return $this->db->execute( - 'DELETE FROM oauth_auth_codes WHERE expires_at <= :cutoff', - ['cutoff' => $cutoff], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to prune authorization codes', layer: 'repository.oauth', previous: $e); - } - } -} diff --git a/plugins/OAuth2/Infrastructure/Persistence/ClientRepository.php b/plugins/OAuth2/Infrastructure/Persistence/ClientRepository.php deleted file mode 100644 index f42360a..0000000 --- a/plugins/OAuth2/Infrastructure/Persistence/ClientRepository.php +++ /dev/null @@ -1,169 +0,0 @@ -db->queryOne( - 'SELECT * FROM oauth_clients WHERE id = :id', - ['id' => $clientId], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to load OAuth client', layer: 'repository.oauth', previous: $e); - } - - if ($row === null) { - return null; - } - - return Client::of( - id: (string) $row['id'], - name: (string) $row['name'], - secretHash: $row['secret_hash'] !== null && $row['secret_hash'] !== '' ? (string) $row['secret_hash'] : null, - redirectUris: $this->decodeList($row['redirect_uris'] ?? null), - grantTypes: $this->decodeList($row['grant_types'] ?? null), - scopes: $this->decodeList($row['scopes'] ?? null), - confidential: (bool) $row['confidential'], - revoked: (bool) $row['revoked'], - ownerId: isset($row['owner_id']) ? (string) $row['owner_id'] : null, - ); - } - - public function create( - string $id, - string $name, - ?string $secretHash, - array $redirectUris, - array $grantTypes, - array $scopes, - bool $confidential, - ?string $ownerId = null, - ): void { - try { - $this->db->execute( - 'INSERT INTO oauth_clients (id, name, secret_hash, redirect_uris, grant_types, scopes, confidential, revoked, owner_id, created_at) - VALUES (:id, :name, :secret, :redirects, :grants, :scopes, :conf, :revoked, :owner, :created)', - [ - 'id' => $id, - 'name' => $name, - 'secret' => $secretHash, - 'redirects' => json_encode(array_values($redirectUris)), - 'grants' => json_encode(array_values($grantTypes)), - 'scopes' => json_encode(array_values($scopes)), - 'conf' => $confidential ? 1 : 0, - 'revoked' => 0, - 'owner' => $ownerId, - 'created' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to create OAuth client', layer: 'repository.oauth', previous: $e); - } - } - - /** @return list */ - public function all(): array - { - try { - $rows = $this->db->query('SELECT * FROM oauth_clients ORDER BY created_at DESC'); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list OAuth clients', layer: 'repository.oauth', previous: $e); - } - - return array_map($this->hydrate(...), $rows); - } - - /** @return list */ - public function findByOwner(string $ownerId): array - { - try { - $rows = $this->db->query( - 'SELECT * FROM oauth_clients WHERE owner_id = :owner ORDER BY created_at DESC', - ['owner' => $ownerId], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list OAuth clients', layer: 'repository.oauth', previous: $e); - } - - return array_map($this->hydrate(...), $rows); - } - - public function updateDetails(string $id, string $name, array $redirectUris, array $scopes): bool - { - try { - return $this->db->execute( - 'UPDATE oauth_clients SET name = :name, redirect_uris = :redirects, scopes = :scopes WHERE id = :id', - [ - 'name' => $name, - 'redirects' => json_encode(array_values($redirectUris)), - 'scopes' => json_encode(array_values($scopes)), - 'id' => $id, - ], - ) === 1; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to update OAuth client', layer: 'repository.oauth', previous: $e); - } - } - - /** @param array $row */ - private function hydrate(array $row): Client - { - return Client::of( - id: (string) $row['id'], - name: (string) $row['name'], - secretHash: $row['secret_hash'] !== null && $row['secret_hash'] !== '' ? (string) $row['secret_hash'] : null, - redirectUris: $this->decodeList($row['redirect_uris'] ?? null), - grantTypes: $this->decodeList($row['grant_types'] ?? null), - scopes: $this->decodeList($row['scopes'] ?? null), - confidential: (bool) $row['confidential'], - revoked: (bool) $row['revoked'], - ownerId: isset($row['owner_id']) ? (string) $row['owner_id'] : null, - ); - } - - public function revoke(string $id): bool - { - try { - return $this->db->execute('UPDATE oauth_clients SET revoked = 1 WHERE id = :id', ['id' => $id]) === 1; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to revoke OAuth client', layer: 'repository.oauth', previous: $e); - } - } - - public function updateSecret(string $id, string $secretHash): bool - { - try { - return $this->db->execute( - 'UPDATE oauth_clients SET secret_hash = :secret WHERE id = :id AND confidential = 1', - ['secret' => $secretHash, 'id' => $id], - ) === 1; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to rotate OAuth client secret', layer: 'repository.oauth', previous: $e); - } - } - - /** @return list */ - private function decodeList(mixed $raw): array - { - if (!is_string($raw) || $raw === '') { - return []; - } - $decoded = json_decode($raw, true); - - return is_array($decoded) ? array_values(array_filter($decoded, 'is_string')) : []; - } -} diff --git a/plugins/OAuth2/Infrastructure/Persistence/DeviceCodeRepository.php b/plugins/OAuth2/Infrastructure/Persistence/DeviceCodeRepository.php deleted file mode 100644 index 2757f68..0000000 --- a/plugins/OAuth2/Infrastructure/Persistence/DeviceCodeRepository.php +++ /dev/null @@ -1,142 +0,0 @@ -db->execute( - 'INSERT INTO oauth_device_codes - (id, device_code_hash, user_code, client_id, scopes, status, user_id, interval_seconds, expires_at, created_at) - VALUES (:id, :hash, :user_code, :client, :scopes, :status, :user, :interval, :expires, :created)', - [ - 'id' => $device->id, - 'hash' => $deviceCodeHash, - 'user_code' => $device->userCode, - 'client' => $device->clientId, - 'scopes' => json_encode(array_values($device->scopes)), - 'status' => $device->status, - 'user' => $device->userId, - 'interval' => $device->interval, - 'expires' => $device->expiresAt->format('Y-m-d H:i:s'), - 'created' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to store device code', layer: 'repository.oauth', previous: $e); - } - } - - public function findByDeviceHash(string $deviceCodeHash): ?DeviceCode - { - return $this->hydrateOne('device_code_hash = :v', ['v' => $deviceCodeHash]); - } - - public function findByUserCode(string $userCode): ?DeviceCode - { - return $this->hydrateOne('user_code = :v', ['v' => $userCode]); - } - - public function authorize(string $id, string $userId): bool - { - try { - return $this->db->execute( - "UPDATE oauth_device_codes SET status = 'authorized', user_id = :user - WHERE id = :id AND status = 'pending'", - ['user' => $userId, 'id' => $id], - ) === 1; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to authorize device code', layer: 'repository.oauth', previous: $e); - } - } - - public function deny(string $id): bool - { - try { - return $this->db->execute( - "UPDATE oauth_device_codes SET status = 'denied' WHERE id = :id AND status = 'pending'", - ['id' => $id], - ) === 1; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to deny device code', layer: 'repository.oauth', previous: $e); - } - } - - public function markPolled(string $id, \DateTimeImmutable $at): void - { - try { - $this->db->execute( - 'UPDATE oauth_device_codes SET last_polled_at = :at WHERE id = :id', - ['at' => $at->format('Y-m-d H:i:s'), 'id' => $id], - ); - } catch (\PDOException) { - // non-fatal — slow_down enforcement degrades gracefully - } - } - - public function consume(string $id): bool - { - try { - return $this->db->execute( - "UPDATE oauth_device_codes SET status = 'denied' WHERE id = :id AND status = 'authorized'", - ['id' => $id], - ) === 1; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to consume device code', layer: 'repository.oauth', previous: $e); - } - } - - public function deleteExpired(?\DateTimeImmutable $now = null): int - { - $cutoff = ($now ?? new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - return $this->db->execute( - 'DELETE FROM oauth_device_codes WHERE expires_at <= :cutoff', - ['cutoff' => $cutoff], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to prune device codes', layer: 'repository.oauth', previous: $e); - } - } - - private function hydrateOne(string $where, array $params): ?DeviceCode - { - try { - $row = $this->db->queryOne("SELECT * FROM oauth_device_codes WHERE {$where}", $params); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to load device code', layer: 'repository.oauth', previous: $e); - } - - if ($row === null) { - return null; - } - - $scopes = json_decode((string) ($row['scopes'] ?? '[]'), true); - - return DeviceCode::of( - id: (string) $row['id'], - userCode: (string) $row['user_code'], - clientId: (string) $row['client_id'], - scopes: is_array($scopes) ? array_values(array_filter($scopes, 'is_string')) : [], - status: (string) $row['status'], - userId: ($row['user_id'] ?? null) ?: null, - interval: (int) $row['interval_seconds'], - lastPolledAt: ($row['last_polled_at'] ?? null) ? new \DateTimeImmutable((string) $row['last_polled_at']) : null, - expiresAt: new \DateTimeImmutable((string) $row['expires_at']), - ); - } -} diff --git a/plugins/OAuth2/Infrastructure/Persistence/RefreshTokenRepository.php b/plugins/OAuth2/Infrastructure/Persistence/RefreshTokenRepository.php deleted file mode 100644 index 6d42481..0000000 --- a/plugins/OAuth2/Infrastructure/Persistence/RefreshTokenRepository.php +++ /dev/null @@ -1,156 +0,0 @@ -db->execute( - 'INSERT INTO oauth_refresh_tokens - (id, family_id, token_hash, client_id, user_id, scopes, revoked, expires_at, created_at) - VALUES (:id, :family, :hash, :client, :user, :scopes, 0, :expires, :created)', - [ - 'id' => $token->id, - 'family' => $token->familyId, - 'hash' => $tokenHash, - 'client' => $token->clientId, - 'user' => $token->userId, - 'scopes' => json_encode(array_values($token->scopes)), - 'expires' => $token->expiresAt->format('Y-m-d H:i:s'), - 'created' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to store refresh token', layer: 'repository.oauth', previous: $e); - } - } - - public function findByHash(string $tokenHash): ?RefreshToken - { - try { - $row = $this->db->queryOne( - 'SELECT * FROM oauth_refresh_tokens WHERE token_hash = :hash', - ['hash' => $tokenHash], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to load refresh token', layer: 'repository.oauth', previous: $e); - } - - if ($row === null) { - return null; - } - - $scopes = json_decode((string) ($row['scopes'] ?? '[]'), true); - - return RefreshToken::of( - id: (string) $row['id'], - familyId: (string) $row['family_id'], - clientId: (string) $row['client_id'], - userId: (string) $row['user_id'], - scopes: is_array($scopes) ? array_values(array_filter($scopes, 'is_string')) : [], - expiresAt: new \DateTimeImmutable((string) $row['expires_at']), - revoked: (bool) $row['revoked'], - ); - } - - /** @return list */ - public function findByUser(string $userId): array - { - try { - $rows = $this->db->query( - 'SELECT * FROM oauth_refresh_tokens - WHERE user_id = :user AND revoked = 0 AND expires_at > :now - ORDER BY expires_at DESC', - ['user' => $userId, 'now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s')], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list refresh tokens', layer: 'repository.oauth', previous: $e); - } - - return array_map($this->hydrate(...), $rows); - } - - /** @return list every active grant in the tenant (admin view). */ - public function allActive(): array - { - try { - $rows = $this->db->query( - 'SELECT * FROM oauth_refresh_tokens - WHERE revoked = 0 AND expires_at > :now - ORDER BY expires_at DESC', - ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s')], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list refresh tokens', layer: 'repository.oauth', previous: $e); - } - - return array_map($this->hydrate(...), $rows); - } - - /** @param array $row */ - private function hydrate(array $row): RefreshToken - { - $scopes = json_decode((string) ($row['scopes'] ?? '[]'), true); - - return RefreshToken::of( - id: (string) $row['id'], - familyId: (string) $row['family_id'], - clientId: (string) $row['client_id'], - userId: (string) $row['user_id'], - scopes: is_array($scopes) ? array_values(array_filter($scopes, 'is_string')) : [], - expiresAt: new \DateTimeImmutable((string) $row['expires_at']), - revoked: (bool) $row['revoked'], - ); - } - - public function revokeIfActive(string $tokenId): bool - { - try { - return $this->db->execute( - 'UPDATE oauth_refresh_tokens SET revoked = 1 WHERE id = :id AND revoked = 0', - ['id' => $tokenId], - ) === 1; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to revoke refresh token', layer: 'repository.oauth', previous: $e); - } - } - - public function revokeFamily(string $familyId): int - { - try { - return $this->db->execute( - 'UPDATE oauth_refresh_tokens SET revoked = 1 WHERE family_id = :family AND revoked = 0', - ['family' => $familyId], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to revoke refresh token family', layer: 'repository.oauth', previous: $e); - } - } - - public function deleteExpired(?\DateTimeImmutable $now = null): int - { - $cutoff = ($now ?? new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - return $this->db->execute( - 'DELETE FROM oauth_refresh_tokens WHERE expires_at <= :cutoff', - ['cutoff' => $cutoff], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to prune refresh tokens', layer: 'repository.oauth', previous: $e); - } - } -} diff --git a/plugins/OAuth2/Infrastructure/Persistence/ScopeRepository.php b/plugins/OAuth2/Infrastructure/Persistence/ScopeRepository.php deleted file mode 100644 index 51d1b05..0000000 --- a/plugins/OAuth2/Infrastructure/Persistence/ScopeRepository.php +++ /dev/null @@ -1,80 +0,0 @@ -db->queryOne( - 'SELECT id FROM oauth_scopes WHERE id = :id', - ['id' => $scope], - ) !== null; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to check scope', layer: 'repository.oauth', previous: $e); - } - } - - /** @return list */ - public function all(): array - { - try { - $rows = $this->db->query('SELECT id FROM oauth_scopes ORDER BY id'); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list scopes', layer: 'repository.oauth', previous: $e); - } - - return array_map(static fn (array $r): string => (string) $r['id'], $rows); - } - - /** @return array */ - public function describe(): array - { - try { - $rows = $this->db->query('SELECT id, description FROM oauth_scopes ORDER BY id'); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list scopes', layer: 'repository.oauth', previous: $e); - } - - $out = []; - foreach ($rows as $r) { - $out[(string) $r['id']] = (string) ($r['description'] ?? ''); - } - - return $out; - } - - public function put(string $id, string $description): void - { - try { - $this->db->upsert( - 'oauth_scopes', - ['id' => $id, 'description' => $description, 'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s')], - ['id'], - ['description'], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to save scope', layer: 'repository.oauth', previous: $e); - } - } - - public function delete(string $id): bool - { - try { - return $this->db->execute('DELETE FROM oauth_scopes WHERE id = :id', ['id' => $id]) > 0; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to delete scope', layer: 'repository.oauth', previous: $e); - } - } -} diff --git a/plugins/OAuth2/OAUTH2_GUIDE.pdf b/plugins/OAuth2/OAUTH2_GUIDE.pdf deleted file mode 100644 index 87b049d6c0508dfd1fb93331ee33269b4649c930..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 199459 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

NVTD9h?wT*VnR}2Qd6=@gPLjel}z_X2Mjs6 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%0Dw=> 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%0D)(AyDTKr1@Q zOCeL0rQ<^HO$?I2+XK`&aRnd7Q#N8lPr(?yI936O4Wwca^WuesIE4S6NQ6m*BSbnv z^tRL<(S3jKO3^I`4ZeMnv`cvi0A|;~1nc7He!+J4RbSEqcY@jS$u|YJ>o?!xD9x0joxwndUQyv@WNRx=Q;)ac^rbmX@%C1xe50cl=uUswpgT;yY09P zKFDm~s>@XD*HERjy_PY;ZorAll2Lwjl1?1Y@-?$h{9>gpwT zr^=yKT=t*56<1e2(bJXeUEY;fJ_^xt7E */ - public function requires(): array - { - return ['database.management', 'crypto.services', 'user.management', 'view.rendering']; - } - - /** @return list */ - public function exposes(): array - { - return [ - ClientStore::class, - \Plugins\OAuth2\Application\Ports\AuthorizationFlow::class, - ]; - } - - public function register(ModuleContainer $container): void - { - // ── persistence (central connection — control plane) ────────────────── - $container->bind(ClientStore::class, static fn(ModuleContainer $c) => - new ClientRepository($c->make(DatabasePort::class))); - $container->bindInternal(AuthCodeStore::class, static fn(ModuleContainer $c) => - new AuthCodeRepository($c->make(DatabasePort::class))); - $container->bindInternal(RefreshTokenStore::class, static fn(ModuleContainer $c) => - new RefreshTokenRepository($c->make(DatabasePort::class))); - $container->bindInternal(ScopeStore::class, static fn(ModuleContainer $c) => - new ScopeRepository($c->make(DatabasePort::class ))); - $container->bindInternal(DeviceCodeStore::class, static fn(ModuleContainer $c) => - new DeviceCodeRepository($c->make(DatabasePort::class ))); - - $container->bindInternal(ScopeValidator::class, static fn(ModuleContainer $c) => - new ScopeValidator($c->make(ScopeStore::class))); - - // ── token signing (shares the platform JWT keys) ────────────────────── - $container->bindInternal(TokenIssuer::class, static fn(ModuleContainer $c) => - new TokenIssuer( - algo: env('JWT_ALGO') ?: 'HS256', - secret: env('JWT_SECRET') ?: '', - privateKey: self::readKey(env('JWT_PRIVATE_KEY'), env('JWT_PRIVATE_KEY_FILE')), - issuer: env('JWT_ISSUER') ?: null, - keyId: env('JWT_KID') ?: null, - accessTtl: (int) (env('OAUTH_ACCESS_TTL') ?: 3600), - // Resource-server audience so access tokens pass JwtAuthLayer's - // audience check; defaults to the platform JWT_AUDIENCE. - audience: env('OAUTH_TOKEN_AUDIENCE') ?: (env('JWT_AUDIENCE') ?: null), - )); - - // ── resource-owner verifier (password grant) ────────────────────────── - $container->bindInternal(ResourceOwnerVerifier::class, static function (ModuleContainer $c): ?ResourceOwnerVerifier { - return $c->has(UserServiceContract::class) - ? new UserResourceOwnerVerifier($c->make(UserServiceContract::class)) - : null; - }); - - // ── services ────────────────────────────────────────────────────────── - $container->bindInternal(AuthorizationService::class, static fn(ModuleContainer $c) => - new AuthorizationService( - $c->make(ClientStore::class), - $c->make(AuthCodeStore::class), - $c->make(ScopeValidator::class), - (int) (env('OAUTH_CODE_TTL') ?: 60), - )); - - // Published port: headless code issuance for first-party authenticated - // flows (Auth's mobile login/register — old __DEV__ PKCE-without-browser). - $container->bind(\Plugins\OAuth2\Application\Ports\AuthorizationFlow::class, - static fn(ModuleContainer $c) => $c->make(AuthorizationService::class)); - - $container->bindInternal(TokenService::class, static fn(ModuleContainer $c) => - new TokenService( - $c->make(ClientStore::class), - $c->make(AuthCodeStore::class), - $c->make(RefreshTokenStore::class), - $c->make(ScopeValidator::class), - $c->make(TokenIssuer::class), - $c->make(HashingPort::class), - $c->make(ResourceOwnerVerifier::class), - (int) (env('OAUTH_REFRESH_TTL') ?: 1209600), - $c->make(DeviceCodeStore::class), - )); - - $container->bindInternal(DeviceService::class, static fn(ModuleContainer $c) => - new DeviceService( - $c->make(ClientStore::class), - $c->make(DeviceCodeStore::class), - $c->make(ScopeValidator::class), - (int) (env('OAUTH_DEVICE_TTL') ?: 600), - (int) (env('OAUTH_DEVICE_INTERVAL') ?: 5), - )); - - // UserInfo (OIDC). Default returns `sub` only; a project may override the - // UserInfoProvider binding with a richer, scope-aware implementation. - $container->bindInternal(UserInfoProvider::class, static fn(ModuleContainer $c) => - new SubjectUserInfoProvider()); - - $container->bindInternal(IntrospectionService::class, static fn(ModuleContainer $c) => - new IntrospectionService( - $c->make(RefreshTokenStore::class), - $c->make(TokenIssuer::class), - self::verifyKey(), - env('JWT_ALGO') ?: 'HS256', - $c->has(CachePort::class) ? $c->make(CachePort::class) : null, - )); - - // ── controllers ─────────────────────────────────────────────────────── - $container->bindInternal(AuthorizationController::class, static fn(ModuleContainer $c) => - new AuthorizationController( - $c->make(ViewRendererContract::class), - $c->make(AuthorizationService::class), - $c->make(SessionPort::class), - $c->make(\Plugins\Pageflow\Http\PageflowResponder::class), - )); - $container->bindInternal(TokenController::class, static fn(ModuleContainer $c) => - new TokenController($c->make(TokenService::class))); - $container->bindInternal(IntrospectionController::class, static fn(ModuleContainer $c) => - new IntrospectionController( - $c->make(IntrospectionService::class), - $c->make(ClientStore::class), - $c->make(HashingPort::class), - )); - $container->bindInternal(JwksController::class, static fn(ModuleContainer $c) => - new JwksController( - env('JWT_ALGO') ?: 'HS256', - self::readKey(env('JWT_PUBLIC_KEY'), env('JWT_PUBLIC_KEY_FILE')), - env('JWT_KID') ?: null, - )); - $container->bindInternal(DiscoveryController::class, static fn(ModuleContainer $c) => - new DiscoveryController($c->make(ScopeStore::class))); - $container->bindInternal(DeviceController::class, static fn(ModuleContainer $c) => - new DeviceController($c->make(DeviceService::class))); - $container->bindInternal(DeviceVerificationController::class, static fn(ModuleContainer $c) => - new DeviceVerificationController( - $c->make(ViewRendererContract::class), - $c->make(DeviceCodeStore::class), - )); - $container->bindInternal(UserInfoController::class, static fn(ModuleContainer $c) => - new UserInfoController($c->make(UserInfoProvider::class))); - - // Scope catalogue (descriptions) + the /oauth/scopes endpoint. - $container->bindInternal(\Plugins\OAuth2\Application\Services\ScopeRegistry::class, static fn(ModuleContainer $c) => - new \Plugins\OAuth2\Application\Services\ScopeRegistry($c->make(ScopeStore::class))); - $container->bindInternal(\Plugins\OAuth2\Infrastructure\Http\Controllers\ScopeController::class, static fn(ModuleContainer $c) => - new \Plugins\OAuth2\Infrastructure\Http\Controllers\ScopeController( - $c->make(\Plugins\OAuth2\Application\Services\ScopeRegistry::class))); - - // Self-service management API (clients + authorized tokens), owner-scoped. - $container->bindInternal(\Plugins\OAuth2\Infrastructure\Http\Controllers\ClientController::class, static fn(ModuleContainer $c) => - new \Plugins\OAuth2\Infrastructure\Http\Controllers\ClientController( - $c->make(ClientStore::class), - $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HashingPort::class), - )); - $container->bindInternal(\Plugins\OAuth2\Infrastructure\Http\Controllers\AuthorizedTokenController::class, static fn(ModuleContainer $c) => - new \Plugins\OAuth2\Infrastructure\Http\Controllers\AuthorizedTokenController( - $c->make(RefreshTokenStore::class))); - - // Tenant-wide ADMIN API (all clients / scopes / authorized grants). - // Gated on OAUTH_ADMIN_ROLE / OAUTH_ADMIN_USERS inside the controller. - $container->bindInternal(\Plugins\OAuth2\Infrastructure\Http\Controllers\AdminController::class, static fn(ModuleContainer $c) => - new \Plugins\OAuth2\Infrastructure\Http\Controllers\AdminController( - $c->make(ClientStore::class), - $c->make(ScopeStore::class), - $c->make(RefreshTokenStore::class), - $c->make(HashingPort::class), - $c->make(UserServiceContract::class), - )); - - // Admin dashboard (GET /oauth/admin) — rendered via Pageflow (component - // "OAuth2/Admin"); its page fetches the JSON admin API same-origin. - $container->bindInternal(\Plugins\OAuth2\Infrastructure\Http\Controllers\AdminUiController::class, static fn(ModuleContainer $c) => - new \Plugins\OAuth2\Infrastructure\Http\Controllers\AdminUiController( - $c->make(\Plugins\Pageflow\Http\PageflowResponder::class))); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // OAuth2 data is tenant-scoped, but the CLI has no Host to route from — - // so the commands take a --tenant option. Build a scoped container on the - // CLI path (deferred so HTTP/worker builds never pay for it) and hand each - // command a TenantConnections resolver (central by default; a named tenant - // with --tenant, when the Tenancy plugin is present). - $cli->defer(static function (CliPipeline $cli): void { - $c = new ModuleContainer($cli->container()); - $c->setScope('database.management'); - (new \Plugins\Database\Provider())->register($c); - $c->setScope((new \Plugins\Crypto\Provider())->solves()); - (new \Plugins\Crypto\Provider())->register($c); - - $connections = self::tenantConnections($c); - $hasher = $c->make(HashingPort::class); - - $cli->command(new \Plugins\OAuth2\Infrastructure\Cli\CreateClientCommand($connections, $hasher)); - $cli->command(new \Plugins\OAuth2\Infrastructure\Cli\ListClientsCommand($connections)); - $cli->command(new \Plugins\OAuth2\Infrastructure\Cli\RevokeClientCommand($connections)); - $cli->command(new \Plugins\OAuth2\Infrastructure\Cli\RotateClientSecretCommand($connections, $hasher)); - $cli->command(new \Plugins\OAuth2\Infrastructure\Cli\PruneCommand($connections)); - }); - } - - /** - * Build the CLI connection resolver: the central/default connection, plus the - * OPTIONAL Tenancy registry + resolver so `--tenant=` can target a - * tenant database. Tenancy is registered into the scoped container only when - * the plugin is present; otherwise the commands run central-only. - */ - private static function tenantConnections(ModuleContainer $c): \Plugins\OAuth2\Infrastructure\Cli\TenantConnections - { - $central = self::central($c); - $registry = null; - $resolver = null; - - if (class_exists(\Plugins\Tenancy\Provider::class)) { - try { - // Tenancy's services depend on Audit (audit.trail); register it first. - $c->setScope((new \Plugins\Audit\Provider())->solves()); - (new \Plugins\Audit\Provider())->register($c); - $c->setScope('tenancy.routing'); - (new \Plugins\Tenancy\Provider())->register($c); - - $registry = $c->make(\Plugins\Tenancy\API\Contracts\TenantRegistryContract::class); - $resolver = $c->make(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class); - } catch (\Throwable) { - $registry = null; - $resolver = null; - } - } - - return new \Plugins\OAuth2\Infrastructure\Cli\TenantConnections($central, $registry, $resolver); - } - - private static function central(ModuleContainer $c): DatabasePort - { - return $c->make(DatabaseConnectionManagerContract::class)->default(); - } - - /** The key used to VERIFY access-token signatures (public key for RS/ES/PS, secret for HS). */ - private static function verifyKey(): string - { - $algo = env('JWT_ALGO') ?: 'HS256'; - $c = $algo[0] ?? 'H'; - if ($c === 'R' || $c === 'E' || $c === 'P') { - return (string) self::readKey(env('JWT_PUBLIC_KEY'), env('JWT_PUBLIC_KEY_FILE')); - } - - return env('JWT_SECRET') ?: ''; - } - - /** Read a PEM key from an inline env value or a file path (file preferred). */ - private static function readKey(mixed $inline, mixed $file): ?string - { - $file = is_string($file) ? trim($file) : ''; - if ($file !== '' && is_readable($file)) { - $contents = file_get_contents($file); - if ($contents !== false && trim($contents) !== '') { - return $contents; - } - } - - $inline = is_string($inline) ? trim($inline) : ''; - - return $inline !== '' ? str_replace('\n', "\n", $inline) : null; - } -} diff --git a/plugins/OAuth2/README.md b/plugins/OAuth2/README.md deleted file mode 100644 index 5cdeb9d..0000000 --- a/plugins/OAuth2/README.md +++ /dev/null @@ -1,545 +0,0 @@ -# OAuth2 Plugin — `Plugins\OAuth2` - -> Native **OAuth 2.1 + OpenID Connect** authorization server for the AlfacodeTeam -> PhpServicePlatform (HKM Kernel). No vendor OAuth stack — it issues **platform -> JWTs** that the Auth plugin's `JwtAuthLayer` already verifies, so an issued -> access token authenticates against the existing SecurityGateway with zero extra -> wiring. - -- **solves:** `oauth.server` · **type:** module (on-demand) -- **requires:** `database.management`, `crypto.services`, `user.management`, `view.rendering` -- **exposes:** `ClientStore`, `AuthorizationFlow` - ---- - -## Table of contents - -1. [What it does](#what-it-does) -2. [Architecture](#architecture) -3. [Setup](#setup) -4. [Configuration (env)](#configuration-env) -5. [All routes](#all-routes) -6. [Grants & flows](#grants--flows) - - [Authorization Code + PKCE (browser)](#1-authorization-code--pkce-browser) - - [First-party native / mobile (in-app login & registration)](#2-first-party-native--mobile-in-app-login--registration) - - [Client Credentials (machine-to-machine)](#3-client-credentials) - - [Refresh Token](#4-refresh-token) - - [Password (legacy)](#5-password-legacy) - - [Device Code (RFC 8628)](#6-device-code) -7. [Token endpoint reference](#token-endpoint-reference) -8. [UserInfo, Introspection, Revocation](#userinfo-introspection-revocation) -9. [Discovery & JWKS](#discovery--jwks) -10. [Scopes](#scopes) -11. [Client management](#client-management) -12. [Admin UI & PKCE simulator](#admin-ui--pkce-simulator) -13. [CLI commands](#cli-commands) -14. [Security model](#security-model) -15. [Troubleshooting](#troubleshooting) - ---- - -## What it does - -Grants supported (all through one server): - -| Grant | Use case | -|---|---| -| `authorization_code` (+ PKCE) | Web apps, SPAs, mobile — the modern default | -| `client_credentials` | Machine-to-machine, no user | -| `refresh_token` | Silent renewal (rotation + reuse-detection) | -| `password` | First-party legacy (discouraged by OAuth 2.1) | -| `urn:ietf:params:oauth:grant-type:device_code` | TVs, CLIs, IoT (RFC 8628) | - -Plus: **OIDC** (`id_token`, `/userinfo`, discovery), **JWKS**, **introspection**, -**revocation**, a **self-service** client/token API, a **tenant-wide admin** API + -Pageflow dashboard, and a **PKCE simulator**. - -Access tokens are signed JWTs (**HS256** by default, **RS256** recommended and used -in the reference setup) carrying `sub`, `aud` (resource), -`azp` (client), and `scope:*` entries in `permissions`. They're verified by the -Auth plugin's `JwtAuthLayer`; revocation works via the refresh-token family + -a JWT `jti` deny-list. - ---- - -## Architecture - -- **GDA layered** — `Domain/` (Client, AuthCode, RefreshToken, DeviceCode, Pkce, - GrantType), `Application/` (services + ports), `Infrastructure/` (repositories, - HTTP controllers, CLI, identity adapters). -- **Ports (published):** `ClientStore`, `AuthorizationFlow` (headless code - issuance for first-party flows). Internal ports: `AuthCodeStore`, - `RefreshTokenStore`, `ScopeStore`, `DeviceCodeStore`, `ResourceOwnerVerifier`, - `UserInfoProvider`. -- **Storage** — every repository binds the **per-request `DatabasePort`**, so the - server tables follow the request connection: **tenant-scoped under Tenancy, - central in a single-DB deployment.** Tables: `oauth_clients`, - `oauth_auth_codes`, `oauth_refresh_tokens`, `oauth_scopes`, `oauth_device_codes`. - They ship in **`database/tenant-template/`** (apply with `tenant:migrate`), not - the project migrate path. -- **Signing** — `TokenIssuer` signs with the platform JWT key - (`JWT_PRIVATE_KEY(_FILE)`, algo `JWT_ALGO`). Verification key is the public key, - published at `/oauth/jwks`. - ---- - -## Setup - -### 1. Register the plugin - -Add the provider to your project bootstrap `withModules([...])`: - -```php -Plugins\OAuth2\Provider::class, // solves: oauth.server -``` - -Requires `database.management`, `crypto.services`, `user.management`, -`view.rendering` to be present (they are, in a standard project). The Pageflow -admin/consent pages additionally need `http.pageflow` — the routes declare it via -`requires: ["http.pageflow"]`, so just make sure the Pageflow plugin is enabled. - -### 2. JWT keys (RS256 recommended) - -Generate an RSA keypair and point env at it: - -```bash -mkdir -p storage/keys -openssl genpkey -algorithm RSA -pkcs8 -out storage/keys/oauth-private.pem -openssl rsa -in storage/keys/oauth-private.pem -pubout -out storage/keys/oauth-public.pem -``` - -```dotenv -JWT_ALGO=RS256 -JWT_PRIVATE_KEY_FILE=/abs/path/storage/keys/oauth-private.pem -JWT_PUBLIC_KEY_FILE=/abs/path/storage/keys/oauth-public.pem -JWT_KID=my-oauth-1 -JWT_ISSUER=https://your-host -JWT_AUDIENCE=https://your-host/api -``` - -> The **same** keys must be used by the Auth `JwtAuthLayer` in `withSecurity([...])` -> so issued tokens verify. HS256 works too (`JWT_ALGO=HS256`, `JWT_SECRET=…`). - -⚠️ **File permissions:** if you serve via nginx + PHP-FPM (www-data), FPM must be -able to **read** the private key, or the token endpoint 500s with *"OpenSSL unable -to validate key"*. Grant group/ACL read (e.g. `chmod 640` when www-data shares the -owner's group, or `setfacl -m u:www-data:r …`). - -### 3. Create the tables - -Server tables are tenant-template migrations: - -```bash -hkm tenant:migrate # applies to every active tenant DB -# single-DB (no Tenancy)? run your normal migrate against the default connection -``` - -### 4. Seed the scope catalogue (optional but recommended) - -Requested scopes are validated against `oauth_scopes`. Seed some (or use the admin -UI → Scopes, or `POST /oauth/admin/scopes`): - -```sql -INSERT INTO oauth_scopes (id, description, created_at) VALUES - ('profile','View your profile', NOW()), - ('email','View your email', NOW()), - ('read','Read your data', NOW()), - ('write','Create and update content', NOW()); -``` - -> An **empty** `scope` request falls back to the client's registered scopes and -> skips the catalogue check — so the flow works before you seed anything. - -### 5. CSRF exemptions (token/JSON endpoints) - -The machine/token endpoints must bypass the browser CSRF layer. In your project's -`withSecurity([... new CsrfTokenLayer(exemptPaths: [...]) ...])`, exempt: - -``` -/oauth/token /oauth/introspect /oauth/revoke /oauth/device_authorization -/oauth/clients /oauth/authorized-tokens /oauth/admin -``` - -The browser **consent** form (`/oauth/authorize` POST) stays protected. - -### 6. Admin access - -Admins are identified by role or an allowlist: - -```dotenv -OAUTH_ADMIN_ROLE=admin # a caller holding this role is admin -OAUTH_ADMIN_USERS=5,42 # …or an explicit user-id allowlist -``` - ---- - -## Configuration (env) - -| Key | Default | Meaning | -|---|---|---| -| `OAUTH_ACCESS_TTL` | `3600` | Access-token lifetime (s) | -| `OAUTH_REFRESH_TTL` | `1209600` | Refresh-token lifetime (s, 14d) | -| `OAUTH_CODE_TTL` | `60` | Authorization-code lifetime (s) | -| `OAUTH_DEVICE_TTL` | `600` | Device-code lifetime (s) | -| `OAUTH_DEVICE_INTERVAL` | `5` | Device polling interval (s) | -| `OAUTH_TOKEN_AUDIENCE` | `JWT_AUDIENCE` | Access-token `aud` | -| `OAUTH_ADMIN_ROLE` | `admin` | Role granting admin API access | -| `OAUTH_ADMIN_USERS` | — | Comma-separated admin user-id allowlist | -| `JWT_ALGO` / `JWT_PRIVATE_KEY(_FILE)` / `JWT_PUBLIC_KEY(_FILE)` / `JWT_KID` / `JWT_ISSUER` / `JWT_AUDIENCE` | — | Signing/verification (shared with Auth) | - ---- - -## All routes - -Everything below is live. `auth` = requires a valid Bearer/session Identity; -`admin` = `auth` **plus** the OAuth admin check. - -> **Response formats.** The **OAuth/OIDC spec endpoints** (`/oauth/token`, -> `/oauth/introspect`, `/oauth/revoke`, `/oauth/device*`, `/oauth/jwks`, discovery) -> return **raw RFC-shaped JSON**. The **management API** (`/oauth/scopes`, -> `/oauth/clients*`, `/oauth/authorized-tokens*`, `/oauth/admin/*`) returns the -> platform envelope **`{ "data": … }`** on success and `{ "error": { code, message } }` -> on failure. Examples below show the payload inside `data`. - -### Core OAuth / OIDC - -| Method | Path | Auth | Purpose | -|---|---|---|---| -| GET | `/oauth/authorize` | session | Consent screen (Pageflow) — starts auth-code flow | -| POST | `/oauth/authorize` | session + CSRF | Consent decision (approve/deny) → redirect with `code` | -| POST | `/oauth/token` | client | Token endpoint (all grants) | -| POST | `/oauth/device_authorization` | client | Device grant start → `device_code` + `user_code` | -| GET/POST | `/oauth/device` | session | Device verification screen (enter `user_code`) | -| GET | `/oauth/userinfo` | Bearer | OIDC UserInfo (`sub`, …) | -| POST | `/oauth/introspect` | client | RFC 7662 token introspection | -| POST | `/oauth/revoke` | client | RFC 7009 token/family revocation | -| GET | `/oauth/jwks` | none | Public signing keys (JWK Set) | -| GET | `/oauth/scopes` | none | Grantable scope catalogue | -| GET | `/.well-known/oauth-authorization-server` | none | RFC 8414 metadata | -| GET | `/.well-known/openid-configuration` | none | OIDC discovery | - -### Self-service (owner-scoped — the caller's own clients/grants) - -| Method | Path | Auth | Purpose | -|---|---|---|---| -| GET | `/oauth/clients` | auth | List **my** clients | -| POST | `/oauth/clients` | auth | Register a client (secret shown once) | -| PUT | `/oauth/clients/{id}` | auth | Update my client | -| DELETE | `/oauth/clients/{id}` | auth | Revoke my client | -| GET | `/oauth/authorized-tokens` | auth | Apps **I** authorized | -| DELETE | `/oauth/authorized-tokens/{id}` | auth | Revoke one of my grants | - -### Admin (tenant-wide) - -| Method | Path | Auth | Purpose | -|---|---|---|---| -| GET | `/oauth/admin` | admin (session) | Pageflow admin dashboard | -| GET | `/oauth/admin/simulate` | admin (session) | Per-client OAuth/PKCE simulator | -| GET | `/oauth/admin/clients` | admin | **All** clients (with owner) | -| POST | `/oauth/admin/clients` | admin | Create a client | -| PUT | `/oauth/admin/clients/{id}` | admin | Update any client | -| POST | `/oauth/admin/clients/{id}/rotate` | admin | Rotate any client's secret | -| DELETE | `/oauth/admin/clients/{id}` | admin | Revoke any client | -| GET | `/oauth/admin/scopes` | admin | Scope catalogue | -| POST | `/oauth/admin/scopes` | admin | Add a scope | -| DELETE | `/oauth/admin/scopes/{id}` | admin | Remove a scope | -| GET | `/oauth/admin/authorized-tokens` | admin | **All** users' active grants | -| DELETE | `/oauth/admin/authorized-tokens/{id}` | admin | Revoke any grant | - -> The first-party **native login/registration** endpoints live in the **Auth** -> plugin (`/auth/mobile/login`, `/auth/mobile/register`) but drive this plugin's -> `AuthorizationFlow` — see [flow 2](#2-first-party-native--mobile-in-app-login--registration). - ---- - -## Grants & flows - -### 1. Authorization Code + PKCE (browser) - -The default for web apps, SPAs and mobile using a system browser. - -``` -# 1) App builds a PKCE pair and sends the user to: -GET /oauth/authorize - ?response_type=code - &client_id= - &redirect_uri= - &scope=profile read # optional (empty → client scopes) - &state= - &code_challenge= - &code_challenge_method=S256 - -# 2) Server: requires a login session (else 302 → /login?redirectTo=…), -# renders the Pageflow consent screen. On "Allow" it 302s back to: -?code=&state= - -# 3) App exchanges the code (holds the verifier): -POST /oauth/token (application/x-www-form-urlencoded) -grant_type=authorization_code -&client_id= -&redirect_uri= -&code= -&code_verifier= # public clients (PKCE); confidential add secret - -→ 200 { "token_type":"Bearer", "access_token":"…", "expires_in":3600, - "refresh_token":"…", "scope":"profile read", "id_token":"…" (if openid) } -``` - -Rules: `redirect_uri` is an **exact** match (no wildcards); PKCE is **mandatory -for public clients**; codes are single-use, hashed at rest, 60s TTL. - -### 2. First-party native / mobile (in-app login & registration) - -For your own Android/iOS app that hosts its **own** login/registration UI — no -browser, no consent screen. The app posts credentials **plus** PKCE params to the -**Auth plugin**, which mints an authorization `code` headlessly via this plugin's -`AuthorizationFlow`, then the app exchanges it here. - -``` -# Register OR login in-app (Auth plugin): -POST /auth/mobile/register { email, username, password, - client_id, redirect_uri, - code_challenge, code_challenge_method:"S256", - state, scope } - → 201 { "code":"…", "state":"…" } - -POST /auth/mobile/login { identifier|email, password, } - → 200 { "code":"…", "state":"…" } - # omit client_id → legacy path returns { user, tokens } directly - -# Then exchange the code for tokens (this plugin): -POST /oauth/token -grant_type=authorization_code & client_id=… & redirect_uri=… & code=… & code_verifier=… - -# Password reset (Auth plugin, OTP): -POST /auth/password/forgot { email } → 200 (OTP emailed) -POST /auth/password/verify-otp { email, otp } → 200 { resetToken } -POST /auth/password/reset { email, token, password } → 200 -``` - -Requirements: the mobile routes must `requires: ["oauth.server"]` (so the flow -resolves) and be CSRF-exempt; the client's `redirect_uri` (e.g. a custom scheme -`com.app.oauth://callback`) must be registered. - -### 3. Client Credentials - -Machine-to-machine, no user context. - -``` -POST /oauth/token -grant_type=client_credentials -&client_id=&client_secret= # or HTTP Basic -&scope=reports:read - -→ 200 { "token_type":"Bearer", "access_token":"…", "expires_in":3600, "scope":"reports:read" } -# no refresh_token -``` - -### 4. Refresh Token - -Rotation + family reuse-detection: each refresh returns a **new** refresh token -and invalidates the old one; replaying an old token revokes the whole family. - -``` -POST /oauth/token -grant_type=refresh_token -&refresh_token= -&client_id= # + secret for confidential -&scope=read # optional — may only NARROW - -→ 200 { access_token, refresh_token (new), expires_in, scope } -``` - -### 5. Password (legacy) - -Discouraged by OAuth 2.1; enable only for trusted first-party clients. - -``` -POST /oauth/token -grant_type=password -&client_id=&client_secret= -&username=&password=

&scope=… -``` - -### 6. Device Code - -For input-constrained devices (RFC 8628). - -``` -# Device: -POST /oauth/device_authorization client_id=&scope=… -→ 200 { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } - -# User (on a phone/laptop): open verification_uri, GET/POST /oauth/device, enter user_code, approve. - -# Device polls: -POST /oauth/token -grant_type=urn:ietf:params:oauth:grant-type:device_code -&device_code=&client_id= -→ authorization_pending | slow_down | 200 { access_token, refresh_token, … } -``` - ---- - -## Token endpoint reference - -`POST /oauth/token` — `application/x-www-form-urlencoded`. **Client authentication** -methods: `client_secret_basic` (HTTP Basic), `client_secret_post` (body), or -`none` (public client identified by `client_id` + PKCE). - -| grant_type | Required fields | -|---|---| -| `authorization_code` | `code`, `redirect_uri`, `client_id` (+ `code_verifier` for public, or secret for confidential) | -| `client_credentials` | `client_id` + secret, `scope` | -| `refresh_token` | `refresh_token`, `client_id` (+ secret); optional narrowing `scope` | -| `password` | `client_id` + secret, `username`, `password`, `scope` | -| device code | `device_code`, `client_id` | - -Success body: `{ token_type, access_token, expires_in, scope, refresh_token?, id_token? }`. -Errors follow RFC 6749: `{ "error": "...", "error_description": "..." }` (e.g. -`invalid_grant`, `invalid_client`, `invalid_scope`, `unsupported_grant_type`, -`unauthorized_client`). - ---- - -## UserInfo, Introspection, Revocation - -``` -GET /oauth/userinfo Authorization: Bearer → { "sub":"…", … } - -POST /oauth/introspect token=[&token_type_hint=…] - → { "active":true, "sub","client_id","scope","exp", … } | { "active":false } - -POST /oauth/revoke token= → 200 { "ok": true } - # revokes the refresh-token family and deny-lists the access token's jti -``` - -`/userinfo` returns `sub` by default; a project can bind a richer, scope-aware -`UserInfoProvider`. - ---- - -## Discovery & JWKS - -``` -GET /.well-known/oauth-authorization-server # RFC 8414 -GET /.well-known/openid-configuration # OIDC -GET /oauth/jwks # public keys clients use to verify JWTs -``` - -Discovery advertises: endpoints, `grant_types_supported`, `response_types_supported=["code"]`, -`code_challenge_methods_supported=["S256","plain"]`, -`token_endpoint_auth_methods_supported=["client_secret_basic","client_secret_post","none"]`, -and `scopes_supported` (from the catalogue). - ---- - -## Scopes - -- `GET /oauth/scopes` — public catalogue `{ "data": { "scopes": [ { "id", "description" } ] } }`. -- Requested scopes must exist in `oauth_scopes` **unless** the request omits scope - (then the client's own registered scopes are used, no catalogue check). -- Namespaced scopes ride as `scope:*` in the JWT `permissions`, so resource routes - can gate on them. -- Manage the catalogue via the admin UI or `POST/DELETE /oauth/admin/scopes`. - ---- - -## Client management - -### Self-service (`/oauth/clients`, owner-scoped) - -``` -POST /oauth/clients Authorization: Bearer -{ "name":"My SPA", "redirect_uris":["https://app.example.com/callback"], - "scopes":["profile","read"], "public":true } - -→ 201 { "data": { "id":"…", "client_secret":"…"(confidential only, shown ONCE), - "redirect_uris":[…], "scopes":[…], "confidential":false } } -``` - -Public (`"public":true`) → no secret, PKCE required, grants -`authorization_code`+`refresh_token`. Confidential → a secret is issued **once**. - -### Admin (`/oauth/admin/clients`, tenant-wide) - -Same shape, but sees/edits **every** client and accepts an explicit -`grant_types` list (`authorization_code`, `refresh_token`, `client_credentials`, -`password`, `urn:ietf:params:oauth:grant-type:device_code`). Scopes are validated -against the catalogue. `.../{id}/rotate` returns a fresh secret once. - ---- - -## Admin UI & PKCE simulator - -Server-rendered through **Pageflow** (federated plugin UI in `ui/`): - -- **`GET /oauth/admin`** — dashboard: all clients (with owner avatar/name/email), - scope catalogue (add/delete), and every active grant (revoke). Session-gated; - a guest is bounced to `/login`, a non-admin gets 403. Its JS calls the - `/oauth/admin/*` JSON API same-origin. -- **`GET /oauth/admin/simulate?client=`** — a per-client **PKCE simulator** - (offline: verifier → challenge → server-verify demo) **plus** a **real** launch - that runs the full Authorization Code + PKCE flow against the live server and - exchanges the code for tokens (uses the page itself as the registered redirect; - one-click "add redirect & enable" if missing). - -The UI ships in `plugins/OAuth2/ui/` (`ui.json`, `admin/Pages/OAuth2/{Admin,Simulate,Consent}.tsx`). -Run `hkm ui sync` + rebuild the frontend to publish changes. - ---- - -## CLI commands - -All are **tenant-aware**: pass `--tenant=` to target one tenant, or -`--all` for the whole fleet; omit to use the central/default connection. - -```bash -hkm oauth:client:create --tenant=acme --public \ - --name="My SPA" --redirect="https://app/callback" \ - --grant=authorization_code,refresh_token --scope="profile read" -hkm oauth:client:list --tenant=acme # or --all -hkm oauth:client:revoke --tenant=acme --client= -hkm oauth:client:rotate --tenant=acme --client= # new secret (confidential) -hkm oauth:prune --all # delete expired codes/tokens/device codes -``` - -Supported `--grant` values: `authorization_code`, `refresh_token`, -`client_credentials`, `password`, `urn:ietf:params:oauth:grant-type:device_code`. - ---- - -## Security model - -- **Signing:** RS/ES/PS or HS. Public key at `/oauth/jwks`; verified by Auth's - `JwtAuthLayer` (issuer/audience bound, clock-skew leeway). -- **PKCE:** mandatory for public clients; S256 preferred; verifier hashed at - authorize-time, checked timing-safe at token-time. -- **redirect_uri:** exact match only, validated **before** any error is redirected - (no open-redirect / error harvesting). -- **Refresh rotation + reuse detection:** replay of a rotated token kills the family. -- **Revocation:** refresh-family drop + JWT `jti` deny-list. -- **CSRF:** browser consent form is protected; token/JSON endpoints are exempt (they - authenticate by client credentials / PKCE / Bearer, not cookies). -- **Tenant isolation:** all server data resolves the per-request `DatabasePort`. - ---- - -## Troubleshooting - -| Symptom | Cause / fix | -|---|---| -| `500 "OpenSSL unable to validate key"` on `/oauth/token` | FPM (www-data) can't read the private key. `chmod 640` / ACL the key + traverse its dir. | -| `400 redirect_uri does not match a registered URI` | The `redirect_uri` sent isn't **exactly** one registered on the client. Register it (admin/console). | -| `400 invalid_scope` | Requested a scope not in `oauth_scopes`. Seed it, or send no `scope`. | -| `500 "Table … oauth_* doesn't exist"` | Run `tenant:migrate` (tables are tenant-scoped) against the DB your host resolves to. | -| `403 "CSRF token missing"` on a JSON/token call | Add that path to the `CsrfTokenLayer` `exemptPaths`. | -| `403 "Administrator access required"` | Add your user id to `OAUTH_ADMIN_USERS` (or grant `OAUTH_ADMIN_ROLE`). | -| Route `404` after editing `module.json` / `proj.json` | Manifests are cached — `rm var/cache/manifests/*.php` to rebuild (nginx+FPM). Also check `proj.json` `routePolicy.disable` didn't veto it. | -| Admin/consent page blank | The Pageflow plugin UI needs `hkm ui sync` + a frontend rebuild. | -| `crypto.subtle is undefined` in the simulator | Insecure context (plain `http://host`) — the shipped page uses a pure-JS SHA-256 fallback; rebuild the frontend. | - ---- - -_Part of the HKM Kernel. See also: `docs/ai-context/26_OAUTH2.md`, -`docs/ai-context/25_AUTH.md` (JWT/session/PAT), `docs/ai-context/23_TENANCY.md`._ diff --git a/plugins/OAuth2/database/migrations/.gitkeep b/plugins/OAuth2/database/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/OAuth2/database/tenant-template/2026_06_27_000010_create_oauth_clients_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000010_create_oauth_clients_table.php deleted file mode 100644 index 0a7c523..0000000 --- a/plugins/OAuth2/database/tenant-template/2026_06_27_000010_create_oauth_clients_table.php +++ /dev/null @@ -1,29 +0,0 @@ -create('oauth_clients', static function ($t) { - $t->string('id', 64)->primary(); - $t->string('name', 150); - $t->string('secret_hash', 255)->nullable(); // null = public client - $t->text('redirect_uris'); // JSON list - $t->text('grant_types'); // JSON list - $t->text('scopes')->nullable(); // JSON list (empty = any) - $t->boolean('confidential')->default(true); - $t->boolean('revoked')->default(false); - $t->timestamp('created_at')->nullable(); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('oauth_clients'); - } -}; diff --git a/plugins/OAuth2/database/tenant-template/2026_06_27_000011_create_oauth_auth_codes_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000011_create_oauth_auth_codes_table.php deleted file mode 100644 index 5357488..0000000 --- a/plugins/OAuth2/database/tenant-template/2026_06_27_000011_create_oauth_auth_codes_table.php +++ /dev/null @@ -1,34 +0,0 @@ -create('oauth_auth_codes', static function ($t) { - $t->string('id', 64)->primary(); - $t->char('code_hash', 64)->unique(); - $t->string('client_id', 64); - $t->string('user_id', 64); - $t->text('redirect_uri'); - $t->text('scopes')->nullable(); - $t->string('code_challenge', 128)->nullable(); - $t->string('code_challenge_method', 10)->nullable(); - $t->string('nonce', 255)->nullable(); - $t->boolean('consumed')->default(false); - $t->timestamp('expires_at'); - $t->timestamp('created_at')->nullable(); - - $t->index(['client_id']); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('oauth_auth_codes'); - } -}; diff --git a/plugins/OAuth2/database/tenant-template/2026_06_27_000012_create_oauth_refresh_tokens_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000012_create_oauth_refresh_tokens_table.php deleted file mode 100644 index dcf02f7..0000000 --- a/plugins/OAuth2/database/tenant-template/2026_06_27_000012_create_oauth_refresh_tokens_table.php +++ /dev/null @@ -1,32 +0,0 @@ -create('oauth_refresh_tokens', static function ($t) { - $t->string('id', 64)->primary(); - $t->string('family_id', 64); - $t->char('token_hash', 64)->unique(); - $t->string('client_id', 64); - $t->string('user_id', 64); - $t->text('scopes')->nullable(); - $t->boolean('revoked')->default(false); - $t->timestamp('expires_at'); - $t->timestamp('created_at')->nullable(); - - $t->index(['family_id']); - $t->index(['client_id']); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('oauth_refresh_tokens'); - } -}; diff --git a/plugins/OAuth2/database/tenant-template/2026_06_27_000013_create_oauth_scopes_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000013_create_oauth_scopes_table.php deleted file mode 100644 index 02896a7..0000000 --- a/plugins/OAuth2/database/tenant-template/2026_06_27_000013_create_oauth_scopes_table.php +++ /dev/null @@ -1,23 +0,0 @@ -create('oauth_scopes', static function ($t) { - $t->string('id', 150)->primary(); // the scope identifier, e.g. "profile" - $t->string('description', 255)->nullable(); - $t->timestamp('created_at')->nullable(); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('oauth_scopes'); - } -}; diff --git a/plugins/OAuth2/database/tenant-template/2026_06_27_000014_create_oauth_device_codes_table.php b/plugins/OAuth2/database/tenant-template/2026_06_27_000014_create_oauth_device_codes_table.php deleted file mode 100644 index badb11c..0000000 --- a/plugins/OAuth2/database/tenant-template/2026_06_27_000014_create_oauth_device_codes_table.php +++ /dev/null @@ -1,33 +0,0 @@ -create('oauth_device_codes', static function ($t) { - $t->string('id', 64)->primary(); - $t->char('device_code_hash', 64)->unique(); - $t->string('user_code', 20)->unique(); - $t->string('client_id', 64); - $t->text('scopes')->nullable(); - $t->string('status', 16)->default('pending'); - $t->string('user_id', 64)->nullable(); - $t->integer('interval_seconds')->default(5); - $t->timestamp('last_polled_at')->nullable(); - $t->timestamp('expires_at'); - $t->timestamp('created_at')->nullable(); - - $t->index(['client_id']); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('oauth_device_codes'); - } -}; diff --git a/plugins/OAuth2/database/tenant-template/2026_07_04_000001_add_owner_to_oauth_clients.php b/plugins/OAuth2/database/tenant-template/2026_07_04_000001_add_owner_to_oauth_clients.php deleted file mode 100644 index 52bb477..0000000 --- a/plugins/OAuth2/database/tenant-template/2026_07_04_000001_add_owner_to_oauth_clients.php +++ /dev/null @@ -1,37 +0,0 @@ -hasTable('oauth_clients') || $schema->hasColumn('oauth_clients', 'owner_id')) { - return; - } - - $schema->table('oauth_clients', static function ($t) { - $t->string('owner_id', 64)->nullable()->comment('user_id of the registering user; null = first-party'); - $t->index(['owner_id'], 'idx_oauth_clients_owner'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - if (!$schema->hasTable('oauth_clients') || !$schema->hasColumn('oauth_clients', 'owner_id')) { - return; - } - - $schema->table('oauth_clients', static function ($t) { - $t->dropIndex('idx_oauth_clients_owner'); - $t->dropColumn('owner_id'); - }); - } -}; diff --git a/plugins/OAuth2/module.json b/plugins/OAuth2/module.json deleted file mode 100644 index aa59e18..0000000 --- a/plugins/OAuth2/module.json +++ /dev/null @@ -1,243 +0,0 @@ -{ - "name": "oauth2", - "version": "1.0.0", - "solves": "oauth.server", - "type": "module", - - "requires": [ - "database.management", - "crypto.services", - "user.management", - "view.rendering", - "auth.identity" - ], - "exposes": [ - "Plugins\\OAuth2\\Application\\Ports\\ClientStore", - "Plugins\\OAuth2\\Application\\Ports\\AuthorizationFlow" - ], - - "views": "resources/views", - - "routes": [ - { - "method": "POST", - "path": "/auth/mobile/login", - "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@login", - "filters": ["throttle:10,1"], - "requires": ["oauth.server"] - }, - { - "method": "POST", - "path": "/auth/mobile/register", - "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@register", - "filters": ["throttle:6,1"], - "requires": ["oauth.server"] - }, - { - "method": "POST", - "path": "/auth/mobile/logout", - "handler": "Plugins\\Auth\\Infrastructure\\Http\\Controllers\\MobileAuthController@logout", - "filters": ["auth"] - }, - - { - "method": "GET", - "path": "/oauth/authorize", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizationController@authorize", - "requires": ["http.pageflow"] - }, - { - "method": "POST", - "path": "/oauth/authorize", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizationController@decision", - "requires": ["http.pageflow"] - }, - { - "method": "POST", - "path": "/oauth/token", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\TokenController@issue", - "filters": ["throttle:30,1"] - }, - { - "method": "POST", - "path": "/oauth/device_authorization", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceController@authorize", - "filters": ["throttle:30,1"] - }, - { - "method": "GET", - "path": "/oauth/device", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceVerificationController@show" - }, - { - "method": "POST", - "path": "/oauth/device", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DeviceVerificationController@submit" - }, - { - "method": "GET", - "path": "/oauth/userinfo", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\UserInfoController@show", - "filters": ["auth"] - }, - { - "method": "POST", - "path": "/oauth/introspect", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\IntrospectionController@introspect" - }, - { - "method": "POST", - "path": "/oauth/revoke", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\IntrospectionController@revoke" - }, - { - "method": "GET", - "path": "/oauth/jwks", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\JwksController@keys" - }, - - { - "method": "GET", - "path": "/oauth/scopes", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ScopeController@index" - }, - { - "method": "GET", - "path": "/oauth/clients", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@forUser", - "filters": ["auth"] - }, - { - "method": "POST", - "path": "/oauth/clients", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@store", - "filters": ["auth", "throttle:20,1"] - }, - { - "method": "PUT", - "path": "/oauth/clients/{id}", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@update", - "filters": ["auth"] - }, - { - "method": "DELETE", - "path": "/oauth/clients/{id}", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\ClientController@destroy", - "filters": ["auth"] - }, - { - "method": "GET", - "path": "/oauth/authorized-tokens", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizedTokenController@forUser", - "filters": ["auth"] - }, - { - "method": "DELETE", - "path": "/oauth/authorized-tokens/{id}", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AuthorizedTokenController@destroy", - "filters": ["auth"] - }, - - { - "method": "GET", - "path": "/oauth/admin", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminUiController@dashboard", - "requires": ["http.pageflow"] - }, - { - "method": "GET", - "path": "/oauth/admin/simulate", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminUiController@simulate", - "requires": ["http.pageflow"] - }, - - { - "method": "GET", - "path": "/oauth/admin/clients", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@clients", - "filters": ["auth"] - }, - { - "method": "POST", - "path": "/oauth/admin/clients", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@createClient", - "filters": ["auth", "throttle:20,1"] - }, - { - "method": "PUT", - "path": "/oauth/admin/clients/{id}", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@updateClient", - "filters": ["auth"] - }, - { - "method": "POST", - "path": "/oauth/admin/clients/{id}/rotate", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@rotateClient", - "filters": ["auth"] - }, - { - "method": "DELETE", - "path": "/oauth/admin/clients/{id}", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@revokeClient", - "filters": ["auth"] - }, - { - "method": "GET", - "path": "/oauth/admin/scopes", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@scopes", - "filters": ["auth"] - }, - { - "method": "POST", - "path": "/oauth/admin/scopes", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@createScope", - "filters": ["auth"] - }, - { - "method": "DELETE", - "path": "/oauth/admin/scopes/{id}", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@deleteScope", - "filters": ["auth"] - }, - { - "method": "GET", - "path": "/oauth/admin/authorized-tokens", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@authorizedTokens", - "filters": ["auth"] - }, - { - "method": "DELETE", - "path": "/oauth/admin/authorized-tokens/{id}", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\AdminController@revokeToken", - "filters": ["auth"] - }, - { - "method": "GET", - "path": "/.well-known/oauth-authorization-server", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DiscoveryController@metadata" - }, - { - "method": "GET", - "path": "/.well-known/openid-configuration", - "handler": "Plugins\\OAuth2\\Infrastructure\\Http\\Controllers\\DiscoveryController@openidConfiguration" - } - ], - - "emits": [], - "listens": [], - - "documentation": "Native OAuth 2.1 authorization server. Grants: authorization_code (+PKCE), client_credentials, refresh_token, password. Access tokens are JWTs signed with the platform JWT keys (verified by Plugins\\Auth JwtAuthLayer). Endpoints under /oauth/* plus RFC 8414 discovery. Control-plane tables (oauth_clients/oauth_auth_codes/oauth_refresh_tokens/oauth_scopes) pinned to central. CLI: oauth:client:create.", - - "config": [ - { "key": "OAUTH_ACCESS_TTL", "type": "int", "required": false }, - { "key": "OAUTH_REFRESH_TTL", "type": "int", "required": false }, - { "key": "OAUTH_CODE_TTL", "type": "int", "required": false }, - { "key": "OAUTH_DEVICE_TTL", "type": "int", "required": false }, - { "key": "OAUTH_DEVICE_INTERVAL", "type": "int", "required": false }, - { "key": "OAUTH_TOKEN_AUDIENCE", "type": "string", "required": false }, - { "key": "OAUTH_ADMIN_ROLE", "type": "string", "required": false }, - { "key": "OAUTH_ADMIN_USERS", "type": "string", "required": false }, - { "key": "JWT_PUBLIC_KEY", "type": "string", "required": false }, - { "key": "JWT_PUBLIC_KEY_FILE", "type": "string", "required": false } - ] -} diff --git a/plugins/OAuth2/resources/views/admin.php b/plugins/OAuth2/resources/views/admin.php deleted file mode 100644 index ffab790..0000000 --- a/plugins/OAuth2/resources/views/admin.php +++ /dev/null @@ -1,381 +0,0 @@ - htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); -?> - - - - - - - OAuth2 Admin - - - -

-
-
-
- -
-

OAuth2 Admin

-

Tenant-wide administration — all clients, scopes and authorized grants.

-
-
-
-
- -
- host - user - -
- - - -
- - - -
- - -
-
-

Clients

Every OAuth client registered in this tenant.

-
- - -
-
-
- - - -
Nameclient_idOwnerTypeScopesStatus
Loading…
-
-
- - - - - - -
- - - - - - - diff --git a/plugins/OAuth2/resources/views/consent.php b/plugins/OAuth2/resources/views/consent.php deleted file mode 100644 index dbd06b3..0000000 --- a/plugins/OAuth2/resources/views/consent.php +++ /dev/null @@ -1,58 +0,0 @@ - $scopes Requested scopes. - * @var string $authzId Opaque reference to the server-stored request. - */ -$e = static fn (string $v): string => htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); -?> - - - - - - - - Authorize <?= $e($clientName) ?> - - - -
-

Authorize

-

is requesting access to your account.

- - -

It will be able to:

-
    - -
  • - -
- - -
- - -
- - -
-
-
- - diff --git a/plugins/OAuth2/resources/views/device.php b/plugins/OAuth2/resources/views/device.php deleted file mode 100644 index 7015af9..0000000 --- a/plugins/OAuth2/resources/views/device.php +++ /dev/null @@ -1,52 +0,0 @@ - htmlspecialchars($v, ENT_QUOTES, 'UTF-8'); -?> - - - - - - - - Connect a device - - - -
-

Connect a device

-

Enter the code shown on your device.

- - -
- - -
- - -
- - -
-
-
- - diff --git a/plugins/OAuth2/ui/admin/Pages/OAuth2/Admin.tsx b/plugins/OAuth2/ui/admin/Pages/OAuth2/Admin.tsx deleted file mode 100644 index 7feda56..0000000 --- a/plugins/OAuth2/ui/admin/Pages/OAuth2/Admin.tsx +++ /dev/null @@ -1,518 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { useAuth, Head, Link } from "@pageflow/react"; -import { toast } from "sonner"; -import { Toaster } from "@ui/sonner"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ui/table"; -import { Badge } from "@ui/badge"; -import { Button } from "@ui/button"; -import { Input } from "@ui/input"; -import { Label } from "@ui/label"; -import { Switch } from "@ui/switch"; -import { Avatar, AvatarFallback, AvatarImage } from "@ui/avatar"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@ui/dialog"; -import { OAUTH_GRANT_TYPES, type AdminClientRow, type OwnerProfile } from "@oauth2"; - -// OAuth2 admin dashboard — a PLUGIN-contributed Pageflow page. The admin surface -// globs plugins/*/admin/Pages/**, so this resolves as component "OAuth2/Admin". -// Server: Plugins\OAuth2 AdminUiController@dashboard (session + admin gated). -// Data comes from the /oauth/admin/* JSON API, fetched same-origin (session cookie). - -type ScopeEntry = { id: string; description: string }; -type Grant = { id: string; client_id: string; user_id: string; scopes: string[]; expires_at: string }; -type Result = { ok: true; data: T } | { ok: false }; - -/* eslint-disable @typescript-eslint/no-explicit-any */ -async function api(path: string, opts: RequestInit = {}): Promise { - const headers: Record = { Accept: "application/json" }; - if (opts.body) headers["Content-Type"] = "application/json"; - const res = await fetch(path, { credentials: "same-origin", headers, ...opts }); - const text = await res.text(); - let body: any = null; - try { - body = text ? JSON.parse(text) : null; - } catch { - body = text; - } - if (!res.ok && res.status !== 204) { - const msg = body?.error?.message || body?.error || body?.message || `HTTP ${res.status}`; - const err: any = new Error(msg); - err.status = res.status; - throw err; - } - return body; -} -const pick = (body: any, key: string): any[] => body?.data?.[key] ?? body?.[key] ?? []; - -// Clipboard that also works in an INSECURE context (plain http://host, where -// navigator.clipboard is undefined) via a legacy execCommand fallback. -function legacyCopy(text: string): boolean { - try { - const ta = document.createElement("textarea"); - ta.value = text; - ta.setAttribute("readonly", ""); - ta.style.position = "fixed"; - ta.style.top = "-1000px"; - ta.style.opacity = "0"; - document.body.appendChild(ta); - ta.focus(); - ta.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(ta); - return ok; - } catch { - return false; - } -} -function copyToClipboard(text: string): Promise { - if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) { - return navigator.clipboard.writeText(text).then( - () => true, - () => legacyCopy(text), - ); - } - return Promise.resolve(legacyCopy(text)); -} -function copyText(text: string) { - void copyToClipboard(text).then((ok) => - ok ? toast.success("Copied", { description: text }) : toast.error("Copy failed"), - ); -} - -function useAction() { - return useCallback(async (fn: () => Promise, ok?: string): Promise> => { - try { - const data = await fn(); - if (ok) toast.success(ok); - return { ok: true, data }; - } catch (e: any) { - toast.error(e?.message ?? String(e)); - return { ok: false }; - } - }, []); -} - -export default function OAuth2Admin() { - const auth = useAuth(); - const [host, setHost] = useState(""); - useEffect(() => setHost(typeof window !== "undefined" ? window.location.host : ""), []); - - return ( - <> - - -
-
-
-
🛡
-
-

OAuth2 Admin

-

- Tenant-wide administration — all clients, scopes and authorized grants. -

-
-
-
- signed in as {auth.fullName || auth.email || auth.userId} -
{host}
-
-
- - - - Clients - Scopes - Grants - - - - - - - - - - - -
- - ); -} - -function OwnerCell({ owner, fallbackId }: { owner?: OwnerProfile | null; fallbackId?: string | null }) { - if (!owner && !fallbackId) return ; - const name = owner?.full_name || owner?.username || fallbackId || "—"; - const initials = (owner?.full_name || owner?.username || owner?.email || fallbackId || "?").slice(0, 2).toUpperCase(); - return ( -
- - {owner?.avatar_url ? : null} - {initials} - -
-
{name}
- {owner?.email &&
{owner.email}
} -
-
- ); -} - -function ClientsCard() { - const run = useAction(); - const [clients, setClients] = useState(null); - const [loading, setLoading] = useState(false); - - const load = useCallback(async () => { - setLoading(true); - const r = await run(() => api("/oauth/admin/clients").then((b) => pick(b, "clients") as AdminClientRow[])); - if (r.ok) setClients(r.data); - setLoading(false); - }, [run]); - useEffect(() => void load(), [load]); - - const rotate = async (id: string) => { - const r = await run(() => api(`/oauth/admin/clients/${encodeURIComponent(id)}/rotate`, { method: "POST" })); - if (r.ok) { - const secret = (r.data.data ?? r.data).client_secret; - await copyToClipboard(secret); - toast.success("Secret rotated — copied", { description: secret }); - } - }; - const revoke = async (id: string, name: string) => { - const r = await run(() => api(`/oauth/admin/clients/${encodeURIComponent(id)}`, { method: "DELETE" }), `Revoked “${name}”`); - if (r.ok) void load(); - }; - - return ( - - -
- Clients - Every OAuth client registered in this tenant. -
-
- - -
-
- -
- - - - Name - client_id - Owner - Type - Scopes - Status - Actions - - - - {clients?.length === 0 && ( - - - No clients yet. - - - )} - {clients?.map((c) => ( - - {c.name} - - - - - - - - {c.confidential ? "confidential" : "public"} - - - {(c.scopes ?? []).join(" ") || "—"} - - - {c.revoked ? revoked : active} - - -
- {!c.revoked && (c.grant_types ?? []).includes("authorization_code") && ( - - )} - {c.confidential && ( - - )} - {!c.revoked && ( - - )} -
-
-
- ))} -
-
-
-
-
- ); -} - -function NewClientDialog({ onCreated }: { onCreated: () => void }) { - const run = useAction(); - const [open, setOpen] = useState(false); - const [name, setName] = useState("Admin-created client"); - const [redirect, setRedirect] = useState(""); - const [scopes, setScopes] = useState(""); - const [grants, setGrants] = useState(["authorization_code", "refresh_token"]); - const [isPublic, setIsPublic] = useState(true); - const [busy, setBusy] = useState(false); - - useEffect(() => { - if (typeof window !== "undefined") setRedirect(window.location.origin + "/oauth/callback"); - }, []); - - const toggle = (g: string) => setGrants((cur) => (cur.includes(g) ? cur.filter((x) => x !== g) : [...cur, g])); - - const submit = async () => { - setBusy(true); - const r = await run( - () => - api("/oauth/admin/clients", { - method: "POST", - body: JSON.stringify({ - name: name.trim(), - redirect_uris: redirect.trim() ? [redirect.trim()] : [], - scopes: scopes.trim() ? scopes.trim().split(/\s+/) : [], - grant_types: grants, - public: isPublic, - }), - }), - `Created “${name.trim()}”`, - ); - setBusy(false); - if (r.ok) { - const c = r.data.data ?? r.data; - if (c.client_secret) { - await copyToClipboard(c.client_secret); - toast.success("client_secret copied (shown once)", { description: c.client_secret }); - } - setOpen(false); - onCreated(); - } - }; - - return ( - - - - - - - Register OAuth client - Created in the current tenant. Confidential secrets are shown once. - -
-
- - setName(e.target.value)} /> -
-
- - setRedirect(e.target.value)} /> -
-
- - setScopes(e.target.value)} /> -

Every scope must already exist in the catalogue.

-
-
- -
- {OAUTH_GRANT_TYPES.map((g) => ( - - ))} -
-
-
-
- -

Off = confidential (issues a secret).

-
- -
-
- - - - - - -
-
- ); -} - -function ScopesCard() { - const run = useAction(); - const [scopes, setScopes] = useState(null); - const [id, setId] = useState(""); - const [desc, setDesc] = useState(""); - - const load = useCallback(async () => { - const r = await run(() => api("/oauth/admin/scopes").then((b) => pick(b, "scopes") as ScopeEntry[])); - if (r.ok) setScopes(r.data); - }, [run]); - useEffect(() => void load(), [load]); - - const add = async () => { - const r = await run( - () => api("/oauth/admin/scopes", { method: "POST", body: JSON.stringify({ id: id.trim(), description: desc.trim() }) }), - `Added “${id.trim()}”`, - ); - if (r.ok) { - setId(""); - setDesc(""); - void load(); - } - }; - const del = async (sid: string) => { - const r = await run(() => api(`/oauth/admin/scopes/${encodeURIComponent(sid)}`, { method: "DELETE" }), `Deleted “${sid}”`); - if (r.ok) void load(); - }; - - return ( - - - Scope catalogue - Grantable scopes shown on consent and validated at /authorize. - - -
-
- - setId(e.target.value)} placeholder="read" /> -
-
- - setDesc(e.target.value)} placeholder="Read your data" /> -
- -
-
- {scopes?.length === 0 &&

No scopes registered.

} - {scopes?.map((s) => ( -
-
- {s.id} - — {s.description || "no description"} -
- -
- ))} -
-
-
- ); -} - -function GrantsCard() { - const run = useAction(); - const [tokens, setTokens] = useState(null); - - const load = useCallback(async () => { - const r = await run(() => api("/oauth/admin/authorized-tokens").then((b) => pick(b, "authorized_tokens") as Grant[])); - if (r.ok) setTokens(r.data); - }, [run]); - useEffect(() => void load(), [load]); - - const revoke = async (id: string) => { - const r = await run(() => api(`/oauth/admin/authorized-tokens/${encodeURIComponent(id)}`, { method: "DELETE" }), "Grant revoked"); - if (r.ok) void load(); - }; - - return ( - - - Authorized grants - Every active refresh-token grant across all users in the tenant. - - -
- - - - grant id - client - user - scopes - expires - Actions - - - - {tokens?.length === 0 && ( - - - No active grants. - - - )} - {tokens?.map((t) => ( - - {t.id} - {t.client_id} - {t.user_id} - {(t.scopes ?? []).join(" ") || "—"} - {t.expires_at} - - - - - ))} - -
-
-
-
- ); -} diff --git a/plugins/OAuth2/ui/admin/Pages/OAuth2/Consent.tsx b/plugins/OAuth2/ui/admin/Pages/OAuth2/Consent.tsx deleted file mode 100644 index 00f0d23..0000000 --- a/plugins/OAuth2/ui/admin/Pages/OAuth2/Consent.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { usePage, Head } from "@pageflow/react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ui/card"; -import { Button } from "@ui/button"; - -// OAuth consent screen — a PLUGIN Pageflow page ("OAuth2/Consent", server: -// AuthorizationController@authorize). Approve/Deny use a NATIVE form POST to -// /oauth/authorize (not a Pageflow XHR) so the browser follows the 302 the -// decision() returns back to the client's redirect_uri. The kernel CSRF token -// rides in `_csrf_token` exactly like the old server-rendered form. - -type Props = { csrf: string; clientName: string; scopes: string[]; authzId: string }; - -export default function OAuth2Consent() { - const { props } = usePage(); - - return ( - <> - -
- - -
🔐
- - Authorize {props.clientName} - - {props.clientName} is requesting access to your account. -
- -
-
This app will be able to
-
    - {props.scopes.length === 0 &&
  • Basic access to your account.
  • } - {props.scopes.map((s) => ( -
  • - - {s} -
  • - ))} -
-
- -
- - - - -
- -

- You can revoke access anytime in your account settings. -

-
-
-
- - ); -} diff --git a/plugins/OAuth2/ui/admin/Pages/OAuth2/Simulate.tsx b/plugins/OAuth2/ui/admin/Pages/OAuth2/Simulate.tsx deleted file mode 100644 index 3ce4a23..0000000 --- a/plugins/OAuth2/ui/admin/Pages/OAuth2/Simulate.tsx +++ /dev/null @@ -1,509 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { Head, Link } from "@pageflow/react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@ui/card"; -import { Button } from "@ui/button"; -import { Input } from "@ui/input"; -import { Label } from "@ui/label"; -import { Badge } from "@ui/badge"; -import type { AdminClientRow } from "@oauth2"; - -// Per-client OAuth simulation — a PLUGIN Pageflow page ("OAuth2/Simulate", server: -// AdminUiController@simulate). Two things in one page: -// • a PKCE demo (offline: verifier → challenge → server-verify), and -// • the REAL Authorization Code + PKCE flow — this same page is the redirect -// target (redirect_uri = {origin}/oauth/admin/simulate), so it launches -// /oauth/authorize and, on return, exchanges the code for real tokens. -// Session-authenticated, same-origin. crypto.subtle is unavailable over plain -// http, so SHA-256 is pure-JS below. - -/* eslint-disable @typescript-eslint/no-explicit-any */ -function b64url(bytes: Uint8Array): string { - let s = ""; - for (const x of bytes) s += String.fromCharCode(x); - return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} -function sha256(ascii: string): Uint8Array { - const rr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); - const K = [ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, - 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, - 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, - 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, - 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, - 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, - 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, - ]; - const bytes: number[] = []; - for (let i = 0; i < ascii.length; i++) { - const c = ascii.charCodeAt(i); - if (c < 0x80) bytes.push(c); - else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); - else bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } - const bitLen = bytes.length * 8; - bytes.push(0x80); - while (bytes.length % 64 !== 56) bytes.push(0); - bytes.push(0, 0, 0, 0, (bitLen >>> 24) & 0xff, (bitLen >>> 16) & 0xff, (bitLen >>> 8) & 0xff, bitLen & 0xff); - let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a; - let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19; - const w = new Array(64); - for (let i = 0; i < bytes.length; i += 64) { - for (let t = 0; t < 16; t++) - w[t] = (bytes[i + t * 4] << 24) | (bytes[i + t * 4 + 1] << 16) | (bytes[i + t * 4 + 2] << 8) | bytes[i + t * 4 + 3]; - for (let t = 16; t < 64; t++) { - const s0 = rr(w[t - 15], 7) ^ rr(w[t - 15], 18) ^ (w[t - 15] >>> 3); - const s1 = rr(w[t - 2], 17) ^ rr(w[t - 2], 19) ^ (w[t - 2] >>> 10); - w[t] = (w[t - 16] + s0 + w[t - 7] + s1) | 0; - } - let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7; - for (let t = 0; t < 64; t++) { - const S1 = rr(e, 6) ^ rr(e, 11) ^ rr(e, 25); - const ch = (e & f) ^ (~e & g); - const t1 = (h + S1 + ch + K[t] + w[t]) | 0; - const S0 = rr(a, 2) ^ rr(a, 13) ^ rr(a, 22); - const maj = (a & b) ^ (a & c) ^ (b & c); - const t2 = (S0 + maj) | 0; - h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0; - } - h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0; - h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0; - } - const hs = [h0, h1, h2, h3, h4, h5, h6, h7]; - const out = new Uint8Array(32); - for (let i = 0; i < 8; i++) { - out[i * 4] = (hs[i] >>> 24) & 0xff; - out[i * 4 + 1] = (hs[i] >>> 16) & 0xff; - out[i * 4 + 2] = (hs[i] >>> 8) & 0xff; - out[i * 4 + 3] = hs[i] & 0xff; - } - return out; -} -const s256 = (v: string) => b64url(sha256(v)); -function genVerifier(len = 64): string { - const a = new Uint8Array(len); - crypto.getRandomValues(a); - const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; - let s = ""; - for (const x of a) s += A[x % A.length]; - return s; -} -function copy(text: string) { - const noop = () => void 0; - if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(noop, noop); - else { - try { - const t = document.createElement("textarea"); - t.value = text; - t.style.position = "fixed"; - t.style.opacity = "0"; - document.body.appendChild(t); - t.select(); - document.execCommand("copy"); - document.body.removeChild(t); - } catch { - /* ignore */ - } - } -} -async function api(path: string, opts: RequestInit = {}): Promise { - const headers: Record = { Accept: "application/json" }; - if (opts.body) headers["Content-Type"] = "application/json"; - const res = await fetch(path, { credentials: "same-origin", headers, ...opts }); - const text = await res.text(); - let body: any = null; - try { - body = text ? JSON.parse(text) : null; - } catch { - body = text; - } - if (!res.ok && res.status !== 204) { - const msg = body?.error?.message || body?.error || body?.message || `HTTP ${res.status}`; - const e: any = new Error(msg); - e.status = res.status; - throw e; - } - return body; -} -const pick = (b: any, k: string): any[] => b?.data?.[k] ?? b?.[k] ?? []; - -type SimState = { verifier: string; clientId: string; redirect: string }; -const SKEY = "oauth2.sim.byState"; -function saveState(state: string, d: SimState) { - try { - const m = JSON.parse(sessionStorage.getItem(SKEY) || "{}"); - m[state] = d; - sessionStorage.setItem(SKEY, JSON.stringify(m)); - } catch { - /* ignore */ - } -} -function loadState(state: string): SimState | undefined { - try { - return (JSON.parse(sessionStorage.getItem(SKEY) || "{}") as Record)[state]; - } catch { - return undefined; - } -} - -export default function OAuth2Simulate() { - const [ready, setReady] = useState(false); - const [q, setQ] = useState({ client: "", code: "", state: "", error: "" }); - useEffect(() => { - const p = new URLSearchParams(window.location.search); - setQ({ client: p.get("client") || "", code: p.get("code") || "", state: p.get("state") || "", error: p.get("error") || "" }); - setReady(true); - }, []); - - return ( - <> - -
-
-
- -
Authorization Code + PKCE (RFC 7636 / 6749)
-
- {!ready ? null : q.code || q.error ? : } -
-
- - ); -} - -// ── real callback: exchange the code for tokens ────────────────────────────── -function CallbackView({ code, state, error }: { code: string; state: string; error: string }) { - const [ok, setOk] = useState(null); - const [out, setOut] = useState("Exchanging authorization code…"); - const [clientId, setClientId] = useState(""); - - useEffect(() => { - if (error) { - setOk(false); - setOut(`Authorization error: ${error}`); - return; - } - const data = loadState(state); - if (!data) { - setOk(false); - setOut("No PKCE verifier stored for this state — launch the flow again from the simulator."); - return; - } - setClientId(data.clientId); - const form = new URLSearchParams({ - grant_type: "authorization_code", - client_id: data.clientId, - redirect_uri: data.redirect, - code, - code_verifier: data.verifier, - }); - fetch("/oauth/token", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: form.toString(), - }) - .then((r) => r.json()) - .then((b) => { - setOk(Boolean(b?.access_token)); - setOut(JSON.stringify(b, null, 2)); - }) - .catch((e) => { - setOk(false); - setOut(String(e)); - }); - }, [code, state, error]); - - return ( - - - - Token exchange {ok === true && success} - {ok === false && failed} - - - Posted code + code_verifier to /oauth/token. - - - -
-          {out}
-        
- -
-
- ); -} - -// ── PKCE demo + real-flow launcher ─────────────────────────────────────────── -function Simulator({ clientId }: { clientId: string }) { - const [method, setMethod] = useState<"S256" | "plain">("S256"); - const [verifier, setVerifier] = useState(""); - const [challenge, setChallenge] = useState(""); - const [state, setState] = useState(""); - const [stored, setStored] = useState(null); - - const [client, setClient] = useState(clientId ? "loading" : "missing"); - const [busy, setBusy] = useState(false); - const [err, setErr] = useState(""); - - const origin = typeof window !== "undefined" ? window.location.origin : ""; - const simRedirect = `${origin}/oauth/admin/simulate`; - - useEffect(() => { - setVerifier(genVerifier(64)); - setState(genVerifier(24)); - }, []); - useEffect(() => { - setChallenge(!verifier ? "" : method === "plain" ? verifier : s256(verifier)); - }, [verifier, method]); - - const loadClient = useCallback(() => { - if (!clientId) return; - api("/oauth/admin/clients") - .then((b) => setClient((pick(b, "clients") as AdminClientRow[]).find((c) => c.id === clientId) ?? "missing")) - .catch((e) => { - setErr(e?.message ?? String(e)); - setClient("missing"); - }); - }, [clientId]); - useEffect(loadClient, [loadClient]); - - const obj = client && client !== "loading" && client !== "missing" ? client : null; - const registered = (obj?.redirect_uris ?? []).includes(simRedirect); - const scope = (obj?.scopes ?? []).join(" "); - - const play = () => { - const v = genVerifier(64); - setVerifier(v); - setState(genVerifier(24)); - const ch = method === "plain" ? v : s256(v); - setChallenge(ch); - setStored(ch); - }; - - const enableRedirect = async () => { - if (!obj) return; - setBusy(true); - setErr(""); - try { - await api(`/oauth/admin/clients/${encodeURIComponent(obj.id)}`, { - method: "PUT", - body: JSON.stringify({ name: obj.name, redirect_uris: [...(obj.redirect_uris ?? []), simRedirect], scopes: obj.scopes ?? [] }), - }); - loadClient(); - } catch (e: any) { - setErr(e?.message ?? String(e)); - } finally { - setBusy(false); - } - }; - - const launch = () => { - if (!obj) return; - saveState(state, { verifier, clientId: obj.id, redirect: simRedirect }); - const qs = new URLSearchParams({ - response_type: "code", - client_id: obj.id, - redirect_uri: simRedirect, - state, - code_challenge: challenge, - code_challenge_method: method, - }); - if (scope) qs.set("scope", scope); - window.location.assign(`/oauth/authorize?${qs.toString()}`); - }; - - const matches = stored === null ? null : challenge !== "" && challenge === stored; - const len = verifier.length; - const valid = len >= 43 && len <= 128 && /^[A-Za-z0-9\-._~]*$/.test(verifier); - - const authorizeUrl = useMemo(() => { - const qs = new URLSearchParams({ - response_type: "code", - client_id: clientId || "", - redirect_uri: obj ? simRedirect : "", - code_challenge: challenge || "…", - code_challenge_method: method, - state: state || "…", - }); - if (scope) qs.set("scope", scope); - return `/oauth/authorize?${qs.toString()}`; - }, [clientId, obj, simRedirect, challenge, method, state, scope]); - - return ( -
-
-

OAuth Simulator

-

- code_challenge = BASE64URL(SHA256(code_verifier)) -

-
- - offline demo — generates a fresh pair + state and verifies it -
-
- - {/* Real flow */} - - - Run the real flow - - Launches /oauth/authorize with a registered web redirect and returns here to - exchange the code. - - - - {client === "loading" &&

Loading client…

} - {client === "missing" && ( -

- {clientId ? ( - <>Client {clientId} not found{err ? ` — ${err}` : ""}. - ) : ( - <>Open this from Admin → “simulate” on a client to run the real flow. (The PKCE demo below works without one.) - )} -

- )} - {obj && ( - <> - - - {err &&
{err}
} - {!registered ? ( -
- This client hasn’t registered the simulation redirect yet. -
- -
-
- ) : ( - - )} - - )} -
-
- - {/* PKCE pair */} - - -
- 1 · The app generates a PKCE pair - The verifier stays on the device; only the challenge goes in the URL. -
-
- - -
-
- -
-
- -
- {len} chars - - -
-
- setVerifier(e.target.value)} /> - {!valid && ( -

- A verifier must be 43–128 chars from [A-Za-z0-9-._~]. -

- )} -
- -
-
- - -
-
{challenge || "…"}
-
- -
-
- - -
-
{state || "…"}
-

A random per-request value the client generates and re-checks on the callback (CSRF defence).

-
- -
- -
GET {authorizeUrl}
-
-
-
- - {/* server check */} - - - 2 · The server verifies at /token - - At /authorize the server stores the challenge; at{" "} - /token the app sends the verifier and the server recomputes it. - - - -
- - {stored !== null && ( - - )} -
- {stored !== null && ( -
- - -
- {matches - ? "✓ PKCE verified — the server issues access_token + refresh_token." - : "✕ invalid_grant — the verifier does not match the stored challenge."} -
-
- )} -
-
-
- ); -} - -function Kv({ k, v }: { k: string; v: string }) { - return ( -
-
{k}
-
{v || "…"}
-
- ); -} diff --git a/plugins/OAuth2/ui/index.ts b/plugins/OAuth2/ui/index.ts deleted file mode 100644 index 50bfb16..0000000 --- a/plugins/OAuth2/ui/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -// OAuth2 plugin UI entry — federated into the app frontend by `hkm ui sync` -// (mirrored to frontend/plugins/oauth2, aliased "@oauth2"). The admin surface -// globs plugins/*/admin/Pages/**, so admin/Pages/OAuth2/Admin.tsx resolves as -// the Pageflow component "OAuth2/Admin" (server: AdminUiController@dashboard). - -export type OwnerProfile = { - id: string; - username?: string; - email?: string; - full_name?: string; - avatar_url?: string | null; -}; - -export type AdminClientRow = { - id: string; - name: string; - redirect_uris?: string[]; - grant_types?: string[]; - scopes?: string[]; - confidential?: boolean; - revoked?: boolean; - owner_id?: string | null; - owner?: OwnerProfile | null; -}; - -export const OAUTH_GRANT_TYPES = [ - "authorization_code", - "refresh_token", - "client_credentials", - "password", - "urn:ietf:params:oauth:grant-type:device_code", -] as const; diff --git a/plugins/OAuth2/ui/ui.json b/plugins/OAuth2/ui/ui.json deleted file mode 100644 index 16ff1dc..0000000 --- a/plugins/OAuth2/ui/ui.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "alias": "@oauth2", - "entry": "index.ts", - "framework": "react", - "surfaces": { - "admin": "admin/Pages", - "site": "site/Pages" - }, - "dependencies": {} -} diff --git a/plugins/Pageflow/API/Contracts/PageflowSharerContract.php b/plugins/Pageflow/API/Contracts/PageflowSharerContract.php deleted file mode 100644 index d73a875..0000000 --- a/plugins/Pageflow/API/Contracts/PageflowSharerContract.php +++ /dev/null @@ -1,22 +0,0 @@ -share()/mergeShared() with request-derived - * data (auth user, flash, CSRF token, locale, …). - */ -interface PageflowSharerContract -{ - public function share(Request $request, PageflowResponder $responder): void; -} diff --git a/plugins/Pageflow/Cli/PageflowTypesCommand.php b/plugins/Pageflow/Cli/PageflowTypesCommand.php deleted file mode 100644 index b0387b4..0000000 --- a/plugins/Pageflow/Cli/PageflowTypesCommand.php +++ /dev/null @@ -1,217 +0,0 @@ -name = 'pageflow:types'; - $this->description = 'Generate TypeScript typings (shared props + page registry) for the Pageflow client'; - - // Empty defaults → auto-discover the frontend layout (see resolvePagesRoots/resolveOut). - $this->addOption('pages', '', 'Directory of page components to enumerate (default: auto-discover the frontend)', acceptsValue: true, default: ''); - $this->addOption('out', '', 'Output .d.ts path (default: frontend/src/pageflow.d.ts)', acceptsValue: true, default: ''); - } - - protected function handle(): int - { - $pagesOpt = (string) $this->option('pages'); - $roots = $pagesOpt !== '' ? [$pagesOpt] : $this->discoverPagesRoots(); - $outPath = (string) $this->option('out') ?: $this->defaultOut(); - - $components = []; - $found = false; - foreach ($roots as $root) { - if (is_dir($root)) { - $found = true; - foreach ($this->scanComponents($root) as $name) { - $components[$name] = true; - } - } - } - if (!$found) { - $this->info(sprintf( - 'No page directories found (%s) — emitting shared-prop typings only.', - implode(', ', $roots) ?: '(none)', - )); - } - $components = array_keys($components); - sort($components); - - $contents = $this->render($components); - - $dir = \dirname($outPath); - if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { - $this->error("Cannot create output directory: {$dir}"); - return 1; - } - - if (@file_put_contents($outPath, $contents) === false) { - $this->error("Failed to write: {$outPath}"); - return 1; - } - - $this->info(sprintf('Wrote %s (%d page component%s).', $outPath, \count($components), \count($components) === 1 ? '' : 's')); - return 0; - } - - /** - * Enumerate component names from *.tsx/*.jsx/*.vue files, using the path - * relative to the pages dir (sans extension) as the component key — matching - * the usual resolvePageComponent() convention (e.g. "Users/Index"). - * - * @return list - */ - private function scanComponents(string $pagesDir): array - { - if (!is_dir($pagesDir)) { - return []; - } - - $names = []; - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($pagesDir, \FilesystemIterator::SKIP_DOTS), - ); - - /** @var \SplFileInfo $file */ - foreach ($iterator as $file) { - if (!$file->isFile()) { - continue; - } - $ext = strtolower($file->getExtension()); - if (!\in_array($ext, ['tsx', 'jsx', 'vue'], true)) { - continue; - } - $relative = ltrim(str_replace('\\', '/', substr($file->getPathname(), \strlen($pagesDir))), '/'); - $name = preg_replace('/\.(tsx|jsx|vue)$/i', '', $relative); - if (is_string($name) && $name !== '') { - $names[$name] = true; - } - } - - $list = array_keys($names); - sort($list); - return $list; - } - - /** - * Auto-discover every `Pages/` root in the `hkm ui` frontend of the active - * project: each surface (frontend/src/surfaces/{name}/Pages) plus the pages - * federated by enabled plugins (frontend/plugins/{name}/{admin,site}/Pages). - * Falls back to the legacy resources/js/Pages when no frontend exists. - * - * @return list - */ - private function discoverPagesRoots(): array - { - $frontend = Paths::project('frontend'); - if (!is_dir($frontend)) { - return [Paths::project('resources/js/Pages')]; - } - - $roots = []; - foreach (glob($frontend . '/src/surfaces/*/Pages', GLOB_ONLYDIR) ?: [] as $dir) { - $roots[] = $dir; - } - foreach (glob($frontend . '/plugins/*/{admin,site}/Pages', GLOB_ONLYDIR | GLOB_BRACE) ?: [] as $dir) { - $roots[] = $dir; - } - - return $roots !== [] ? $roots : [$frontend . '/src/Pages']; - } - - /** Default output path — beside the frontend source, else legacy resources/js. */ - private function defaultOut(): string - { - $frontend = Paths::project('frontend'); - return is_dir($frontend) - ? $frontend . '/src/pageflow.d.ts' - : Paths::project('resources/js/pageflow.d.ts'); - } - - /** @param list $components */ - private function render(array $components): string - { - $generated = date('c'); - - $registry = $components === [] - ? " // No page components discovered. Augment this interface to type a page:\n" - . " // 'Users/Index': { users: User[] }\n" - : implode("\n", array_map( - static fn(string $name): string => sprintf(' %s: Record', self::quote($name)), - $components, - )); - - return << - csrf_token?: string - } - - /** - * Registry of page component name -> its props. Augment per page in your app: - * declare module '@pageflow/react' { - * interface PageflowPages { 'Users/Index': { users: User[] } } - * } - */ - export interface PageflowPages { - {$registry} - } - - declare module '@pageflow/core' { - interface PageProps extends PageflowSharedProps {} - } - - declare module '@pageflow/react' { - interface PageflowPages {} - } - - TS; - } - - private static function quote(string $value): string - { - return "'" . str_replace("'", "\\'", $value) . "'"; - } -} diff --git a/plugins/Pageflow/Http/CallablePageflowSharer.php b/plugins/Pageflow/Http/CallablePageflowSharer.php deleted file mode 100644 index 75644c0..0000000 --- a/plugins/Pageflow/Http/CallablePageflowSharer.php +++ /dev/null @@ -1,29 +0,0 @@ -fn = $fn; - } - - public function share(Request $request, PageflowResponder $responder): void - { - ($this->fn)($request, $responder); - } -} diff --git a/plugins/Pageflow/Http/CompositePageflowSharer.php b/plugins/Pageflow/Http/CompositePageflowSharer.php deleted file mode 100644 index 768066d..0000000 --- a/plugins/Pageflow/Http/CompositePageflowSharer.php +++ /dev/null @@ -1,41 +0,0 @@ - */ - private array $sharers; - - public function __construct(PageflowSharerContract ...$sharers) - { - $this->sharers = array_values($sharers); - } - - /** Append a contributor (returns $this for fluent wiring). */ - public function add(PageflowSharerContract $sharer): self - { - $this->sharers[] = $sharer; - return $this; - } - - public function share(Request $request, PageflowResponder $responder): void - { - foreach ($this->sharers as $sharer) { - $sharer->share($request, $responder); - } - } -} diff --git a/plugins/Pageflow/Http/PageflowAuth.php b/plugins/Pageflow/Http/PageflowAuth.php deleted file mode 100644 index 97209e2..0000000 --- a/plugins/Pageflow/Http/PageflowAuth.php +++ /dev/null @@ -1,83 +0,0 @@ -). - * - * The DEFAULT exposes only non-sensitive fields (never tokens). Projects that - * don't want to leak their internal permission vocabulary — or want to send - * coarse capability booleans instead — register their own projector ONCE at - * bootstrap: - * - * pageflow_auth_projection(fn(?Identity $id) => [ - * 'userId' => $id?->userId ?? '', - * 'authenticated' => $id !== null && !$id->isGuest(), - * 'canManage' => (bool) $id?->hasPermission('admin:manage'), - * ]); - * - * The projector is a definition (a static holder), evaluated per request with - * the current Identity — safe under OpenSwoole. - */ -final class PageflowAuth -{ - /** @var null|callable(?Identity):array */ - private static $projector = null; - - /** Override the projection. Pass null to restore the default. */ - public static function project(?callable $projector): void - { - self::$projector = $projector; - } - - /** - * Resolve the shareable projection for an identity (null => guest). - * - * @return array - */ - public static function resolve(?Identity $identity): array - { - if (self::$projector !== null) { - return (self::$projector)($identity); - } - - return self::default($identity); - } - - /** @return array */ - private static function default(?Identity $identity): array - { - if ($identity === null || $identity->isGuest()) { - return [ - 'userId' => '', - 'tenantId' => '', - 'username' => '', - 'fullName' => '', - 'email' => '', - 'avatarUrl' => null, - 'roles' => [], - 'permissions' => [], - 'authenticated' => false, - ]; - } - - // Display identity (username/fullName/email/avatarUrl) is filled on the - // Identity at issuance — non-sensitive, safe to share for UI (useAuth()). - return [ - 'userId' => $identity->userId, - 'tenantId' => $identity->tenantId, - 'username' => $identity->username, - 'fullName' => $identity->fullName, - 'email' => $identity->email, - 'avatarUrl' => $identity->avatarUrl, - 'roles' => $identity->roles, - 'permissions' => $identity->permissions, - 'authenticated' => true, - ]; - } -} diff --git a/plugins/Pageflow/Http/PageflowChannel.php b/plugins/Pageflow/Http/PageflowChannel.php deleted file mode 100644 index cd663e6..0000000 --- a/plugins/Pageflow/Http/PageflowChannel.php +++ /dev/null @@ -1,135 +0,0 @@ -touch("dashboard:{$tenantId}", ['orders', 'stats']); - * which bumps a monotonically-increasing version and records the stale keys in - * the CachePort. The SSE stream watches that version — a cheap cache read per - * tick, NOT a DB scan — and forwards the keys to subscribed clients, who then - * partial-reload through the normal authorized pipeline. - * - * SECURITY: the channel NAME is the scope. Build it from identity/tenant on the - * server (never from client input) so one principal can't watch another's - * channel. Only key NAMES travel — never data — so the stream leaks nothing. - */ -final class PageflowChannel -{ - private const PREFIX = 'pageflow:chan:'; - /** How far back a resuming client may replay (bounds the cache reads). */ - private const MAX_LOOKBACK = 50; - /** TTL for a version's key list — long enough to survive a reconnect. */ - private const KEYS_TTL = 300; - - public function __construct(private readonly CachePort $cache) - { - } - - /** - * Mark prop keys stale on a channel. Returns the new version. - * - * @param list $keys - */ - public function touch(string $channel, array $keys): int - { - $channel = $this->normalize($channel); - $clean = array_values(array_filter( - $keys, - static fn($k): bool => is_string($k) && $k !== '', - )); - if ($clean === []) { - return (int) ($this->cache->get($this->versionKey($channel)) ?? 0); - } - - $version = $this->cache->increment($this->versionKey($channel)); - $this->cache->set($this->keysKey($channel, $version), $clean, self::KEYS_TTL); - - return $version; - } - - /** - * The stale keys accumulated since `$since`, plus the current version to - * resume from. Replay is capped at MAX_LOOKBACK versions. - * - * @return array{0:int,1:list} [currentVersion, keys] - */ - public function stale(string $channel, int $since): array - { - $channel = $this->normalize($channel); - $current = (int) ($this->cache->get($this->versionKey($channel)) ?? 0); - - if ($current <= $since) { - return [$current, []]; - } - - $from = max($since, $current - self::MAX_LOOKBACK) + 1; - $keys = []; - for ($v = $from; $v <= $current; $v++) { - $batch = $this->cache->get($this->keysKey($channel, $v)); - if (is_array($batch)) { - foreach ($batch as $key) { - if (is_string($key) && $key !== '') { - $keys[$key] = true; // dedupe - } - } - } - } - - return [$current, array_keys($keys)]; - } - - /** - * Open an authenticated SSE stream for a channel. The cursor is carried in - * the SSE `id:` field, so a reconnecting browser resumes exactly (via - * Last-Event-ID) without replaying the whole history. - */ - public function stream(Request $request, string $channel, int $intervalMs = 2000, int $maxSeconds = 300): Response - { - $self = $this; - $channel = $this->normalize($channel); - - // Resume from Last-Event-ID; otherwise start at the current version so a - // fresh subscriber isn't spammed with historical changes. - $header = (string) ($request->header('Last-Event-ID') ?? ''); - $cursor = ctype_digit($header) - ? (int) $header - : (int) ($this->cache->get($this->versionKey($channel)) ?? 0); - - $resolver = static function () use ($self, $channel, &$cursor): array { - [$version, $keys] = $self->stale($channel, $cursor); - $cursor = $version; - return ['keys' => $keys, 'id' => (string) $version]; - }; - - // A bounded lifetime recycles the connection (EventSource auto-reconnects) - // so a stream can't pin a worker forever — the key DoS guard under FPM. - return PageflowStream::open($request, $resolver, $intervalMs, $maxSeconds); - } - - private function normalize(string $channel): string - { - // Restrict to a safe key charset to prevent cache-key injection. - $clean = preg_replace('/[^A-Za-z0-9:_\-.]/', '', $channel) ?? ''; - return $clean !== '' ? $clean : 'default'; - } - - private function versionKey(string $channel): string - { - return self::PREFIX . $channel . ':v'; - } - - private function keysKey(string $channel, int $version): string - { - return self::PREFIX . $channel . ':k:' . $version; - } -} diff --git a/plugins/Pageflow/Http/PageflowEndpointsController.php b/plugins/Pageflow/Http/PageflowEndpointsController.php deleted file mode 100644 index dade54b..0000000 --- a/plugins/Pageflow/Http/PageflowEndpointsController.php +++ /dev/null @@ -1,56 +0,0 @@ - a fresh CSRF token for a long-lived SPA - * GET /pageflow/stream -> the reactive SSE channel (auth-gated) - * - * SECURITY: - * • /pageflow/stream is scoped to the caller's TENANT — the client picks only a - * topic (?channel=dashboard); the server prefixes it with the tenant so one - * tenant can never watch another's channel. Emitters touch the SAME composed - * name: $channel->touch("t:{$tenantId}:dashboard", ['orders']). For per-user - * topics, include the userId in the topic on both ends. - * • Only key NAMES flow over the stream — never data — so even a forged - * subscription leaks nothing (the client re-fetches through the pipeline). - */ -final class PageflowEndpointsController -{ - public function __construct( - private readonly PageflowResponder $responder, - private readonly PageflowChannel $channel, - ) { - } - - /** GET /pageflow/csrf — refresh an expired token without a full reload. */ - public function csrf(Request $request): Response - { - return $this->responder->csrfResponse($request); - } - - /** GET /pageflow/stream — tenant-scoped reactive channel (SSE). */ - public function stream(Request $request): Response - { - $identity = $request->identity(); - $tenant = $identity !== null && $identity->tenantId !== '' ? $identity->tenantId : 'central'; - - $topic = preg_replace('/[^A-Za-z0-9_\-.]/', '', (string) ($request->query('channel') ?? 'default')); - $topic = $topic !== '' ? $topic : 'default'; - - $channel = "t:{$tenant}:{$topic}"; - $interval = (int) (env('PAGEFLOW_STREAM_INTERVAL') ?: 2000); - // Bounded lifetime (DoS guard) — the client transparently reconnects. - $maxSeconds = (int) (env('PAGEFLOW_STREAM_MAX_SECONDS') ?: 300); - - return $this->channel->stream($request, $channel, $interval, $maxSeconds); - } -} diff --git a/plugins/Pageflow/Http/PageflowPage.php b/plugins/Pageflow/Http/PageflowPage.php deleted file mode 100644 index b7f9515..0000000 --- a/plugins/Pageflow/Http/PageflowPage.php +++ /dev/null @@ -1,81 +0,0 @@ - $props */ - public function __construct( - public string $component, - public array $props, - public string $url, - public string $version, - public bool $clearHistory = false, - public bool $encryptHistory = false, - ) { - } - - /** @return array */ - public function toArray(): array - { - return [ - 'component' => $this->component, - 'props' => $this->props, - 'url' => $this->url, - 'version' => $this->version, - // Spec-complete for the Pageflow (Inertia v2) client — it reads these - // to decide whether to wipe / encrypt browser-history state. - 'clearHistory' => $this->clearHistory, - 'encryptHistory' => $this->encryptHistory, - ]; - } - - public function toJson(): string - { - - return json_encode($this->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } - - /** HTML-escaped JSON for embedding in the root element's data-page attribute. */ - public function dataPageAttribute(): string - { - return htmlspecialchars($this->toJson(), ENT_QUOTES, 'UTF-8'); - } - - /** - * The Pageflow mount point the client boots from. - * - * The current (Inertia v2) client reads the page object from the root - * element's `data-page` attribute — see createPageflowApp(): - * JSON.parse(el.dataset.page) - * so the object MUST live there, not on window.initialPage. - */ - public function mount(string $appId = 'app'): string - { - return sprintf( - '
', - htmlspecialchars($appId, ENT_QUOTES, 'UTF-8'), - $this->dataPageAttribute(), - ); - } - - /** - * @deprecated The legacy client booted from window.initialPage; the current - * Pageflow client boots from the data-page attribute (see mount()). Kept - * only for backward compatibility with old bundles. - */ - public function renderScript(): string - { - return ''; - } -} diff --git a/plugins/Pageflow/Http/PageflowResponder.php b/plugins/Pageflow/Http/PageflowResponder.php deleted file mode 100644 index 600f9cf..0000000 --- a/plugins/Pageflow/Http/PageflowResponder.php +++ /dev/null @@ -1,321 +0,0 @@ - JSON page object - * - full page load - * -> HTML document that boots the client from `window.initialPage` - * - * Wire protocol matches the legacy HKM\lib\PageFlow implementation: - * - initial load renders a PHP layout template via ob_start (the template has - * $FLOW_PAGE in scope and echoes $FLOW_PAGE->renderScript()), exposing the - * page object as `window.initialPage` so existing clients boot unchanged; - * - shared data (see share()/mergeShared()) is merged into every page's props, - * page props winning on key collision; - * - partial reloads honour X-Pageflow-Partial-Data / -Except / -Component; - * - loadPage:false navigations rewrite url/component from - * X-Pageflow-Url / X-Pageflow-Page. - * - * Shared data is held per instance (NOT static) so it is request-scoped and - * safe under OpenSwoole; the module binds the responder as a per-request - * singleton so share() and render() see the same bag. - */ -final class PageflowResponder -{ - /** @var array */ - private array $shared = []; - - /** - * @param string $layoutPath absolute path to the PHP layout (root view) template - * rendered via ob_start for a full page load. The - * Provider resolves a relative PAGEFLOW_ROOT_VIEW - * against the active project root. In scope: - * $FLOW_PAGE (PageflowPage), $FLOW_CSRF (string - * token), $FLOW_APP_ID (root element id). Empty - * falls back to a minimal document. - * @param (\Closure(Request):string)|null $csrfResolver mints the CSRF token - * for the current request so the client can echo it - * back in the X-CSRF-Token header. Null = no token - * (safe/GET-only apps). - */ - public function __construct( - private readonly string $version, - private readonly string $layoutPath, - private readonly string $appId = 'app', - private readonly ?\Closure $csrfResolver = null, - ) { - } - - /** - * Signal that a PRECOGNITION request passed validation with no side effects. - * Return this from a controller after building a validated DTO when - * pageflow_precognition($request) is true. The client treats 2xx as "valid". - */ - public function precognitionSuccess(): Response - { - return Response::json(['errors' => (object) []], 200, [ - 'X-Pageflow' => 'true', - 'Precognition' => 'true', - 'Precognition-Success' => 'true', - 'Vary' => 'X-Pageflow, Precognition', - ]); - } - - /** - * Fresh-CSRF endpoint. Wire a project route to this (GET /pageflow/csrf) so a - * long-lived SPA can refresh a token that expired since page load and avoid a - * spurious 403 on its next mutation. Never cached. - */ - public function csrfResponse(Request $request): Response - { - return Response::json(['token' => $this->csrfFor($request)], 200, [ - 'Cache-Control' => 'no-store', - ]); - } - - /** Mint the CSRF token for this request, or '' when unavailable. */ - private function csrfFor(Request $request): string - { - if ($this->csrfResolver === null) { - return ''; - } - - return (string) ($this->csrfResolver)($request); - } - - /** Register a single shared prop present on every rendered page. */ - public function share(string $key, mixed $value): void - { - $this->shared[$key] = $value; - } - - /** - * Merge many shared props at once. - * - * @param array $data - */ - public function mergeShared(array $data): void - { - $this->shared = array_merge($this->shared, $data); - } - - /** - * @param string $surface the `hkm ui` surface (buildable app: "admin", - * "project", …) whose bundle boots this page. Required — - * it selects which Vite entry/manifest the HTML shell - * loads (see resources/layouts/app.php), so a page is - * always rendered into the intended surface. - * @param array $props - * @param string|null $viteEntry override the Vite entry point (manifest key, - * relative to the frontend/ root) for the HTML shell. - * Null (default) → the surface's conventional entry - * "src/surfaces/{surface}/index.tsx". - * @param bool $loadPage false for a client-driven partial navigation that - * supplies its own url/component via headers. - * @param bool $cacheable opt THIS page into the service-worker offline cache - * (adds X-Pageflow-Cache: 1). Use ONLY for pages with no - * user-specific data — the SW caches nothing by default. - */ - public function render( - Request $request, - string $component, - string $surface, - array $props = [], - ?string $viteEntry = null, - bool $loadPage = true, - bool $cacheable = false, - ): Response { - // Shared props first, page props override (matches legacy array_merge). - $props = array_merge($this->shared, $props); - - // SECURITY: the CSRF token is intentionally NOT injected as a prop. The - // client reads it from the tag on the HTML shell. - // Keeping it out of the page-object JSON stops it landing in XHR response - // bodies and the service-worker page cache. It still rides the HTML head - // below (htmlResponse) for the initial load; long-lived tabs refresh via - // GET /pageflow/csrf. - $csrf = $this->csrfFor($request); - - $props = $this->resolvePartial($request, $component, $props); - - $url = $this->fullUrl($request); - if (!$loadPage) { - $headerUrl = (string) ($request->header('X-Pageflow-Url') ?? ''); - $parsed = $headerUrl !== '' ? (parse_url($headerUrl, PHP_URL_PATH) ?: $url) : $url; - $url = (string) $parsed; - $component = (string) ($request->header('X-Pageflow-Page') ?? $component); - } - - $page = new PageflowPage( - component: $component, - props: $props, - url: $url, - version: $this->version, - ); - - return $this->isPageflow($request) - ? $this->jsonResponse($page, $cacheable) - : $this->htmlResponse($page, $csrf, $surface, $viteEntry, $cacheable); - } - - /** - * A pageflow (XHR) request carries the X-Pageflow header (any value) OR - * negotiates JSON via Accept — matching the legacy detection. - */ - public function isPageflow(Request $request): bool - { - if ((string) ($request->header('X-Pageflow') ?? '') !== '') { - return true; - } - - return str_contains(strtolower((string) ($request->header('Accept') ?? '')), 'application/json'); - } - - private function jsonResponse(PageflowPage $page, bool $cacheable = false): Response - { - $headers = [ - 'X-Pageflow' => 'true', - 'Vary' => 'X-Pageflow', - ]; - // Opt-in offline caching (SW honours X-Pageflow-Cache). Default off so - // authenticated page objects are never cached at rest by accident. - if ($cacheable) { - $headers['X-Pageflow-Cache'] = '1'; - } - - return Response::json($page->toArray(), 200, $headers); - } - - private function htmlResponse(PageflowPage $page, string $csrf, string $surface, ?string $viteEntry, bool $cacheable = false): Response - { - $html = ($this->layoutPath !== '' && is_file($this->layoutPath)) - ? $this->renderLayout($this->layoutPath, $page, $csrf, $surface, $viteEntry) - : $this->defaultDocument($page, $csrf); - - $response = Response::text($html, 200)->withHeader('Content-Type', 'text/html; charset=UTF-8'); - - return $cacheable ? $response->withHeader('X-Pageflow-Cache', '1') : $response; - } - - /** - * Render a PHP layout template, capturing its output with ob_start. The - * template runs in an isolated scope with only the Pageflow variables - * available ($FLOW_PAGE, $FLOW_CSRF, $FLOW_APP_ID, $FLOW_SURFACE, - * $FLOW_VITE_ENTRY) — no globals leak in. - */ - private function renderLayout(string $path, PageflowPage $page, string $csrf, string $surface, ?string $viteEntry): string - { - $capture = static function ( - string $__path, - PageflowPage $FLOW_PAGE, - string $FLOW_CSRF, - string $FLOW_APP_ID, - string $FLOW_SURFACE, - ?string $FLOW_VITE_ENTRY - ): string { - ob_start(); - try { - require $__path; - } catch (\Throwable $e) { - ob_end_clean(); - throw $e; - } - return ob_get_clean() ?: ''; - }; - - return $capture($path, $page, $csrf, $this->appId, $surface, $viteEntry); - } - - /** - * Partial reload: when the client asks for a subset of props on the same - * component, return only those (or all except the excluded ones). - * - * @param array $props - * @return array - */ - private function resolvePartial(Request $request, string $component, array $props): array - { - $only = $this->headerList($request, 'X-Pageflow-Partial-Data'); - $except = $this->headerList($request, 'X-Pageflow-Partial-Except'); - $target = (string) ($request->header('X-Pageflow-Partial-Component') ?? ''); - - // Partial rules only apply when the requested component matches. - if (($only === [] && $except === []) || ($target !== '' && $target !== $component)) { - return $props; - } - - if ($only !== []) { - $props = array_intersect_key($props, array_flip($only)); - } - if ($except !== []) { - $props = array_diff_key($props, array_flip($except)); - } - - return $props; - } - - /** @return list */ - private function headerList(Request $request, string $name): array - { - $raw = (string) ($request->header($name) ?? ''); - if ($raw === '') { - return []; - } - return array_values(array_filter(array_map('trim', explode(',', $raw)), static fn(string $s) => $s !== '')); - } - - private function fullUrl(Request $request): string - { - $path = $request->path(); - $query = http_build_query($request->queryAll()); - return $query === '' ? $path : $path . '?' . $query; - } - - private function defaultDocument(PageflowPage $page, string $csrf): string - { - // The client boots from the root element's data-page attribute — NOT - // window.initialPage. PageflowPage::mount() emits the correct element. - $csrfMeta = $csrf !== '' - ? '' - : ''; - - // Reserved `seoHead` prop (see the stock layout): render it into - // and STRIP it from the client payload — it is server-only HTML, and - // shipping it in data-page would only bloat the boot JSON. A plain-text - // value (the XHR tab-title string) renders as an escaped . - $seoHead = (string) ($page->props['seoHead'] ?? ''); - if ($seoHead !== '') { - $page = new PageflowPage( - component: $page->component, - props: array_diff_key($page->props, ['seoHead' => true]), - url: $page->url, - version: $page->version, - clearHistory: $page->clearHistory, - encryptHistory: $page->encryptHistory, - ); - } - $seoBlock = match (true) { - $seoHead === '' => '', - str_contains($seoHead, '<') => $seoHead, - default => '<title>' . htmlspecialchars($seoHead, ENT_QUOTES, 'UTF-8') . '', - }; - - return '' . "\n" - . '' - . '' - . $csrfMeta - . $seoBlock - . '' . $page->mount($this->appId) . ''; - } -} diff --git a/plugins/Pageflow/Http/PageflowShares.php b/plugins/Pageflow/Http/PageflowShares.php deleted file mode 100644 index bf06b87..0000000 --- a/plugins/Pageflow/Http/PageflowShares.php +++ /dev/null @@ -1,53 +0,0 @@ - */ - private static array $contributors = []; - - /** Register a raw contributor: fn(Request, PageflowResponder): void. */ - public static function add(callable $contributor): void - { - self::$contributors[] = $contributor; - } - - /** - * Register one keyed share whose value is resolved per request. - * - * @param callable(Request): mixed $resolver - */ - public static function key(string $key, callable $resolver): void - { - self::$contributors[] = static function (Request $request, PageflowResponder $responder) use ($key, $resolver): void { - $responder->share($key, $resolver($request)); - }; - } - - /** @return list */ - public static function all(): array - { - return self::$contributors; - } - - /** Clear all registered contributors (tests / re-bootstrap). */ - public static function flush(): void - { - self::$contributors = []; - } -} diff --git a/plugins/Pageflow/Http/PageflowStage.php b/plugins/Pageflow/Http/PageflowStage.php deleted file mode 100644 index 2109682..0000000 --- a/plugins/Pageflow/Http/PageflowStage.php +++ /dev/null @@ -1,210 +0,0 @@ -isPageflow($request) && strtoupper($request->method()) === 'GET') { - $clientVersion = (string) ($request->header('X-Pageflow-Version') ?? ''); - $currentVersion = (string) (env('PAGEFLOW_VERSION') ?: ''); - if ($currentVersion !== '' && $clientVersion !== $currentVersion) { - return Response::json([], 409, [ - 'X-Pageflow-Location' => $this->fullUrl($request), - ]); - } - } - - // 2. Flag precognition requests so every layer can refuse side effects. - if ($this->isPrecognitive($request)) { - $rollback = (bool) (env('PAGEFLOW_PRECOGNITION_ROLLBACK') ?: false); - $request = $request - ->withAttribute('precognition', true) - ->withAttribute('precognition_fields', $this->precognitionFields($request)) - ->withAttribute('precognition_rollback', $rollback); - } - - // 3. Populate shared props once modules are loaded. - $container = $request->container(); - if ($container !== null - && $container->has(PageflowResponder::class) - && $container->has(PageflowSharerContract::class) - ) { - /** @var PageflowResponder $responder */ - $responder = $container->make(PageflowResponder::class); - /** @var PageflowSharerContract $sharer */ - $sharer = $container->make(PageflowSharerContract::class); - $sharer->share($request, $responder); - } - - // 4. Wrap execution to translate validation errors into Pageflow's shape. - try { - return $next($request); - } catch (ValidationException $e) { - if (!$this->isPageflow($request)) { - throw $e; // let the kernel ErrorStage render it (non-SPA client) - } - - $errors = $this->flatten($e->errors); - - if ($this->isPrecognitive($request)) { - return Response::json(['errors' => $errors], 422, [ - 'X-Pageflow' => 'true', - 'Precognition' => 'true', - 'Vary' => 'X-Pageflow, Precognition', - ]); - } - - $session = $this->session($request); - if ($session !== null) { - $bag = (string) ($request->header('X-Pageflow-Error-Bag') ?? ''); - $session->flash(self::ERROR_FLASH_KEY, $bag !== '' ? [$bag => $errors] : $errors); - - return Response::redirect($this->backUrl($request), 303); - } - - // No session plugin — the client's useForm won't auto-populate, but a - // manual onError handler still receives these. Session is recommended. - return Response::json(['errors' => $errors], 422, ['X-Pageflow' => 'true']); - } - } - - private function isPageflow(Request $request): bool - { - return strtolower((string) ($request->header('X-Pageflow') ?? '')) === 'true'; - } - - private function isPrecognitive(Request $request): bool - { - return strtolower((string) ($request->header('Precognition') ?? '')) === 'true'; - } - - private function session(Request $request): ?SessionPort - { - $container = $request->container(); - if ($container !== null && $container->has(SessionPort::class)) { - /** @var SessionPort $session */ - $session = $container->make(SessionPort::class); - return $session; - } - return null; - } - - private function fullUrl(Request $request): string - { - $path = $request->path(); - $query = http_build_query($request->queryAll()); - return $query === '' ? $path : $path . '?' . $query; - } - - /** @return list */ - private function precognitionFields(Request $request): array - { - $raw = (string) ($request->header('Precognition-Validate-Only') ?? ''); - if ($raw === '') { - return []; - } - return array_values(array_filter( - array_map('trim', explode(',', $raw)), - static fn(string $s): bool => $s !== '', - )); - } - - /** - * Prefer the referer; fall back to the client-declared URL, then the path. - * - * SECURITY: every candidate is reduced to a same-origin path (+query) — the - * scheme/host are stripped — so the 303 Location can never point off-site. - */ - private function backUrl(Request $request): string - { - $referer = $this->pathOnly((string) ($request->header('referer') ?? '')); - if ($referer !== '') { - return $referer; - } - - $headerUrl = $this->pathOnly((string) ($request->header('X-Pageflow-Url') ?? '')); - if ($headerUrl !== '') { - return $headerUrl; - } - - return $request->path(); - } - - /** Reduce any URL to a same-origin "/path?query" (never a scheme/host). */ - private function pathOnly(string $url): string - { - if ($url === '') { - return ''; - } - - $path = parse_url($url, PHP_URL_PATH); - if (!is_string($path) || $path === '') { - return ''; - } - - // Guard against protocol-relative ("//evil.com/x") and backslash tricks. - $path = '/' . ltrim(str_replace('\\', '/', $path), '/'); - - $query = parse_url($url, PHP_URL_QUERY); - return is_string($query) && $query !== '' ? $path . '?' . $query : $path; - } - - /** - * Normalise ValidationException errors (string|string[]) to field => message. - * - * @param array $errors - * @return array - */ - private function flatten(array $errors): array - { - $out = []; - foreach ($errors as $field => $message) { - $out[(string) $field] = is_array($message) - ? (string) ($message[0] ?? '') - : (string) $message; - } - return $out; - } -} diff --git a/plugins/Pageflow/Http/PageflowStream.php b/plugins/Pageflow/Http/PageflowStream.php deleted file mode 100644 index b5bd370..0000000 --- a/plugins/Pageflow/Http/PageflowStream.php +++ /dev/null @@ -1,163 +0,0 @@ -dashboard->drainStaleKeys($r->identity()); - * }); - */ -final class PageflowStream -{ - /** - * @param callable(Request):(array|array{keys:array,id?:string}) $staleKeys - * resolver returning the stale prop keys since the previous tick - * (scoped to the request's identity/tenant). Return [] when nothing - * changed. May instead return {keys, id} to drive the SSE `id:` cursor - * so reconnecting clients resume via Last-Event-ID. - * @param int $intervalMs poll cadence in milliseconds (min 250). - * @param int $maxSeconds hard lifetime cap; 0 = until the client disconnects. - */ - public static function open( - Request $request, - callable $staleKeys, - int $intervalMs = 2000, - int $maxSeconds = 0, - ): Response { - // Fail closed: an unauthenticated principal gets no channel. - $identity = $request->identity(); - if ($identity === null || $identity->isGuest()) { - return Response::json( - ['error' => ['code' => 'pageflow.stream.unauthenticated', 'message' => 'Authentication required.']], - 401, - ); - } - - $interval = max(250, $intervalMs) * 1000; // → microseconds - $deadline = $maxSeconds > 0 ? microtime(true) + $maxSeconds : 0.0; - - $emit = static function (callable $stale) use ($request, $interval, $deadline): void { - self::disableOutputBuffering(); - - // Initial comment forces proxies to open the stream immediately. - echo ": pageflow stream open\n\n"; - self::flush(); - - while (true) { - if (self::clientGone()) { - return; - } - - $result = $stale($request); - - // Resolver may return a plain key list, or {keys, id} to drive - // the SSE cursor for reconnect-safe resume via Last-Event-ID. - $rawKeys = \is_array($result) && \array_key_exists('keys', $result) - ? $result['keys'] - : $result; - $eventId = \is_array($result) && isset($result['id']) ? (string) $result['id'] : null; - - $keys = array_values(array_filter( - (array) $rawKeys, - static fn($k): bool => \is_string($k) && $k !== '', - )); - - if ($eventId !== null) { - echo 'id: ' . str_replace(["\r", "\n"], '', $eventId) . "\n"; - } - - if ($keys !== []) { - echo "event: stale\n"; - echo 'data: ' . json_encode( - ['keys' => $keys], - JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, - ) . "\n\n"; - } else { - // Keep-alive so intermediaries don't drop an idle connection. - echo "event: ping\n"; - echo "data: {}\n\n"; - } - self::flush(); - - if ($deadline > 0.0 && microtime(true) >= $deadline) { - return; - } - - self::sleep($interval); - } - }; - - return Response::stream(static fn() => $emit($staleKeys), 200, [ - 'Content-Type' => 'text/event-stream; charset=UTF-8', - 'Cache-Control' => 'no-cache, no-transform', - 'Connection' => 'keep-alive', - // Disable nginx proxy buffering so events flush in real time. - 'X-Accel-Buffering' => 'no', - ]); - } - - private static function disableOutputBuffering(): void - { - while (ob_get_level() > 0) { - @ob_end_flush(); - } - @ini_set('zlib.output_compression', '0'); - } - - private static function flush(): void - { - if (function_exists('ob_flush')) { - @ob_flush(); - } - @flush(); - } - - private static function clientGone(): bool - { - // Under FPM connection_aborted() reflects the client; Swoole ignores it - // and relies on the deadline / its own connection lifecycle. - return function_exists('connection_aborted') && connection_aborted() === 1; - } - - /** Coroutine-safe sleep under OpenSwoole; plain usleep otherwise. */ - private static function sleep(int $microseconds): void - { - if (class_exists('\OpenSwoole\Coroutine') && \method_exists('\OpenSwoole\Coroutine', 'usleep')) { - \OpenSwoole\Coroutine::usleep($microseconds); - return; - } - if (class_exists('\Swoole\Coroutine') && \method_exists('\Swoole\Coroutine', 'usleep')) { - \Swoole\Coroutine::usleep($microseconds); - return; - } - usleep($microseconds); - } -} diff --git a/plugins/Pageflow/Http/RegistryPageflowSharer.php b/plugins/Pageflow/Http/RegistryPageflowSharer.php deleted file mode 100644 index ea09ca7..0000000 --- a/plugins/Pageflow/Http/RegistryPageflowSharer.php +++ /dev/null @@ -1,23 +0,0 @@ - */ - public function requires(): array - { - return ['vite.manifest']; - } - - /** @return list */ - public function exposes(): array - { - return [PageflowResponder::class]; - } - - public function register(ModuleContainer $container): void - { - // Per-request singleton so share()/mergeShared() and render() see one bag. - $container->singleton(PageflowResponder::class, static function () { - // Mint the platform CSRF token for the current request so the client - // can echo it back in X-CSRF-Token. The token is HMAC(APP_KEY, binding) - // where the binding is the raw session-cookie value the CsrfTokenLayer - // pins to (must be in Cookie's encrypt_exempt). Empty APP_KEY = no token - // (mirrors the layer's fail-closed default; a GET-only app needs none). - $csrfCookie = env('PAGEFLOW_CSRF_COOKIE') ?: 'hkm_session'; - // MUST match the lifetime configured on the CsrfTokenLayer in - // withSecurity([...]); a mismatch makes valid() reject the token. - $csrfLifetime = (int) (env('PAGEFLOW_CSRF_LIFETIME') ?: 43200); - - $csrfResolver = static function (Request $request) use ($csrfCookie, $csrfLifetime): string { - $secret = (string) (env('APP_KEY') ?: ''); - if ($secret === '') { - return ''; - } - $binding = (string) ($request->cookie($csrfCookie) ?? ''); - return CsrfTokenLayer::make($secret, $binding, $csrfLifetime); - }; - - // Root view (HTML shell for the initial load). A relative PAGEFLOW_ROOT_VIEW - // is resolved against the ACTIVE PROJECT ROOT so it loads regardless of the - // process CWD (the kernel rarely runs from the project dir); an absolute - // path is honoured as-is. - $rootView = (string) (env('PAGEFLOW_ROOT_VIEW') ?: 'resources/layouts/app.php'); - $isAbsolute = $rootView !== '' - && ($rootView[0] === '/' || preg_match('#^[A-Za-z]:[\\\\/]#', $rootView) === 1); - $layoutPath = $isAbsolute ? $rootView : Paths::project($rootView); - - return new PageflowResponder( - version: env('PAGEFLOW_VERSION') ?: '1', - layoutPath: $layoutPath, - appId: env('PAGEFLOW_APP_ID') ?: 'app', - csrfResolver: $csrfResolver, - ); - }); - - // Default sharer: runs every contributor registered via pageflow_share(). - // Bind your own PageflowSharerContract in the project to override. - $container->bind(PageflowSharerContract::class, static fn() => new RegistryPageflowSharer()); - - // Emit side of reactive props. Depends on the (essential) CachePort, so - // it is resolvable from every request. Inject it into a Service to touch - // channels after a commit, or into a controller to open the SSE stream. - $container->bind(PageflowChannel::class, static fn($c) => new PageflowChannel( - $c->make(CachePort::class), - )); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // One stage carries the whole Pageflow HTTP protocol at after.load (the - // container/responder exist there): stale-asset 409 guard, precognition - // flagging, shared-prop population (do_action('pageflow_share')), and the - // ValidationException → Pageflow error envelope translation around $next. - $http->hook('after.load', PageflowStage::class, priority: 40); - - // CLI: generate TypeScript typings (shared props + page registry). - $cli->command(PageflowTypesCommand::class); - - // ── Built-in shared props ──────────────────────────────────────────── - // Auth projection on every page (UI: useAuth()/). NON-SENSITIVE - // fields only — never tokens. This is for UX gating; the Service layer - // remains the real authorization boundary. - pageflow_share('pageflow_auth', static function (Request $request): array { - return PageflowAuth::resolve($request->identity()); - }); - - // Validation errors flashed by PageflowStage on the previous - // request surface here (UI: useForm reads props.errors). Pull-and-clear. - pageflow_share('errors', static function (Request $request): array { - $container = $request->container(); - if ($container === null || !$container->has(SessionPort::class)) { - return []; - } - /** @var SessionPort $session */ - $session = $container->make(SessionPort::class); - $errors = $session->pull(PageflowStage::ERROR_FLASH_KEY, []); - return is_array($errors) ? $errors : []; - }); - } -} diff --git a/plugins/Pageflow/README.md b/plugins/Pageflow/README.md deleted file mode 100644 index fcee14c..0000000 --- a/plugins/Pageflow/README.md +++ /dev/null @@ -1,130 +0,0 @@ -# Pageflow - -The SPA bridge for the AlfacodeTeam PhpServicePlatform — a fork of **Inertia.js -v2**, rebranded and wired into the kernel, plus platform-native capabilities -Inertia doesn't have (secure realtime, native validation/precognition, -permission-aware UI, offline, end-to-end types). - -Write normal server controllers that return a **component name + props**; the -client swaps React components in place — SPA feel, no REST API, no client router. - -- **Solves:** `http.pageflow` -- **PHP:** `plugins/Pageflow/` -- **Client:** `plugins/Pageflow/ui/` (`@pageflow/core`, `@pageflow/react`) -- **Full usage guide (PDF):** [`ui/PAGEFLOW_USAGE.pdf`](ui/PAGEFLOW_USAGE.pdf) -- **Architecture/flow guide (PDF):** [`ui/PAGEFLOW_GUIDE.pdf`](ui/PAGEFLOW_GUIDE.pdf) - -## Quick start - -**1. Env** (`.env`): - -``` -PAGEFLOW_VERSION="1" -PAGEFLOW_ROOT_VIEW="/abs/path/plugins/Pageflow/resources/views/app.php" -PAGEFLOW_APP_ID="app" -PAGEFLOW_CSRF_COOKIE="hkm_session" # must be in Cookie's encrypt_exempt -``` - -**2. Register** the plugin in your project `bootstrap/app.php` -(`Plugins\Pageflow\Provider::class`). - -**3. Controller** — inject `PageflowResponder`, return `render()`: - -```php -final class UserController -{ - public function __construct( - private readonly PageflowResponder $pageflow, - private readonly UserServiceContract $users, - ) {} - - public function index(Request $request): Response - { - return $this->pageflow->render($request, 'Users/Index', 'admin', [ - 'users' => $this->users->all(), - ]); - } -} -``` - -**4. Client** (`main.tsx`): - -```tsx -import { createPageflowApp, resolvePageComponent, installCsrfAutoRefresh } from '@pageflow/react' -import { createRoot } from 'react-dom/client' - -installCsrfAutoRefresh() -createPageflowApp({ - resolve: (name) => resolvePageComponent(name, import.meta.glob('./Pages/**/*.tsx')), - setup: ({ el, App, props }) => createRoot(el).render(), -}) -``` - -## Feature map - -| Need | Client | Server | -|---|---|---| -| Link between pages | `` | route → controller | -| Read controller data | `usePage()` | `render(...)` props | -| Form + validation errors | `useForm()` / `
` | DTO throws `ValidationException` | -| Live validation | `usePrecognition` / `` | `pageflow_precognition()` | -| Realtime updates | `useReactiveProps` | `PageflowChannel::touch()` | -| Permission-gated UI | `useAuth()` / `` | `pageflow_auth` (auto) | -| Set page title | `` | — | -| SEO head + tab title | automatic (`seoHead` prop) | `seoFor()` / `seoPrivate()` | -| Offline | `registerPageflowSW()` | `render(..., cacheable: true)` | -| Typed props | `usePage()` | `hkm pageflow:types` | - -## SEO — the reserved `seoHead` prop - -A controller passes ONE reserved prop and the whole SEO surface is handled: - -```php -return $this->pageflow->render($request, 'Shop/Product', 'project', props: [ - 'sku' => $sku, - 'seoHead' => $this->seoFor( // Project\…\InteractsWithGraphSeo - title: $name, description: $desc, path: "/product/{$sku}", - image: "/img/p/{$sku}.jpg", type: 'product', - ), - // auth-gated / token pages: 'seoHead' => $this->seoPrivate('Your profile'), -]); -``` - -How it flows — no other wiring needed: - -- **Full page load** → `seoHead` is the rendered SEO HTML block (title, - description, canonical, robots, hreflang, OG/Twitter, JSON-LD `@graph`). The - layout echoes it into `` and STRIPS it from the client boot payload - (the block contains a literal ``; it must never ride - `window.initialPage` / `data-page`). -- **XHR navigation** (`X-Pageflow`) → the helpers skip ALL the OG/graph work and - return just the plain suffixed tab title (`"Product X · Site"`). The React - `App` syncs `document.title` from it on every navigation — pages do NOT need - `` for titles; the server is the single source of truth. Values - containing markup are ignored client-side (plain text only). -- Crawlers only ever take the full-load path, so SEO is complete without SSR. - -`` remains available for anything else a page wants to inject into the -head (extra meta, links) — just don't use it for the title on pages that pass -`seoHead`. - -## Security invariants (do not regress) - -- **Push signals, pull data** — the reactive channel emits prop key *names* only; - data is always re-fetched through the authenticated pipeline. -- **CSRF token stays same-origin** and lives only in the `` tag + the - throttled `/pageflow/csrf` endpoint (never in page-object JSON or SW cache). -- **Client permission checks are UX only** — the Service layer is the authority. -- **Offline caching is opt-in** — authenticated pages are never cached by default. - -See the full guide PDFs for cookbook examples, the wire protocol, and the -security hardening ledger. - -## Endpoints - -| Route | Purpose | Filters | -|---|---|---| -| `GET /pageflow/csrf` | refresh CSRF token | `throttle` | -| `GET /pageflow/stream` | reactive SSE channel (tenant-scoped) | `auth`, `throttle` | - -> The SSE stream holds a connection — run it under OpenSwoole, not a small PHP-FPM pool. diff --git a/plugins/Pageflow/Support/helpers.php b/plugins/Pageflow/Support/helpers.php deleted file mode 100644 index 68b18f1..0000000 --- a/plugins/Pageflow/Support/helpers.php +++ /dev/null @@ -1,107 +0,0 @@ - [ - * 'userId' => $id?->userId ?? '', - * 'canManage' => (bool) $id?->hasPermission('admin:manage'), - * ]); - * - * SECURITY: keep it minimal — never expose tokens, and prefer capability - * booleans over raw permission strings if the naming is sensitive. - */ - function pageflow_auth_projection(?callable $projector): void - { - PageflowAuth::project($projector); - } -} - -if (!function_exists('pageflow_precognition')) { - /** - * True when the current request is a Pageflow PRECOGNITION request — the - * client wants validation run WITHOUT executing the action. - * - * A precognitive controller MUST short-circuit before any side effect: - * - * public function store(Request $request): Response - * { - * $dto = CreateUserDTO::fromRequest($request); // throws ValidationException - * if (pageflow_precognition($request)) { - * return $this->pageflow->precognitionSuccess(); // validated, no writes - * } - * // ... real work only reached on a normal submit - * } - * - * The PageflowStage turns any ValidationException into the 422 - * error envelope the client reads, so you only handle the success path. - */ - function pageflow_precognition(Request $request): bool - { - return strtolower((string) ($request->header('Precognition') ?? '')) === 'true'; - } -} - -if (!function_exists('pageflow_precognition_fields')) { - /** - * The subset of fields a precognition request asked to validate (empty = all). - * - * @return list - */ - function pageflow_precognition_fields(Request $request): array - { - $raw = (string) ($request->header('Precognition-Validate-Only') ?? ''); - if ($raw === '') { - return []; - } - return array_values(array_filter( - array_map('trim', explode(',', $raw)), - static fn(string $s): bool => $s !== '', - )); - } -} - -if (!function_exists('pageflow_share')) { - /** - * Register shared Pageflow props, present on every rendered page. - * - * Call ONCE at bootstrap (or a plugin's boot) — you register a definition, - * not a value; the value is resolved per request. Two forms: - * - * // keyed share — resolver receives the current Request - * pageflow_share('auth', fn($request) => $request->identity()?->userId); - * pageflow_share('year', fn() => date('Y')); - * - * // raw contributor — full control, share()/mergeShared() many keys - * pageflow_share(function ($request, $responder) { - * $responder->mergeShared(['appName' => 'HKM', 'locale' => 'en']); - * }); - * - * @param string|callable $key Share key, or a raw contributor callable - * fn(Request, PageflowResponder): void. - * @param callable|null $resolver When $key is a string: fn(Request): mixed. - */ - function pageflow_share(string|callable $key, ?callable $resolver = null): void - { - if (!is_string($key)) { - PageflowShares::add($key); - return; - } - - if ($resolver === null) { - throw new InvalidArgumentException( - 'pageflow_share(string $key, callable $resolver): a resolver is required when a key is given.' - ); - } - - PageflowShares::key($key, $resolver); - } -} diff --git a/plugins/Pageflow/module.json b/plugins/Pageflow/module.json deleted file mode 100644 index edcc529..0000000 --- a/plugins/Pageflow/module.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "pageflow", - "version": "1.0.0", - "solves": "http.pageflow", - "type": "module", - - "requires": ["vite.manifest"], - "exposes": [ - "Plugins\\Pageflow\\Http\\PageflowResponder", - "Plugins\\Pageflow\\Http\\PageflowChannel" - ], - - "routes": [ - { - "method": "GET", - "path": "/pageflow/csrf", - "handler": "Plugins\\Pageflow\\Http\\PageflowEndpointsController@csrf", - "filters": ["throttle:60,1"] - }, - { - "method": "GET", - "path": "/pageflow/stream", - "handler": "Plugins\\Pageflow\\Http\\PageflowEndpointsController@stream", - "filters": ["auth", "throttle:30,1"] - } - ], - "emits": [], - "listens": [], - - "config": [ - { "key": "PAGEFLOW_VERSION", "type": "string", "required": false }, - { "key": "PAGEFLOW_ROOT_VIEW", "type": "string", "required": false }, - { "key": "PAGEFLOW_APP_ID", "type": "string", "required": false }, - { "key": "PAGEFLOW_CSRF_COOKIE", "type": "string", "required": false }, - { "key": "PAGEFLOW_CSRF_LIFETIME", "type": "int", "required": false }, - { "key": "PAGEFLOW_STREAM_INTERVAL", "type": "int", "required": false }, - { "key": "PAGEFLOW_STREAM_MAX_SECONDS", "type": "int", "required": false }, - { "key": "PAGEFLOW_PRECOGNITION_ROLLBACK", "type": "bool", "required": false } - ] -} diff --git a/plugins/Pageflow/resources/layouts/app.php b/plugins/Pageflow/resources/layouts/app.php deleted file mode 100644 index 4def753..0000000 --- a/plugins/Pageflow/resources/layouts/app.php +++ /dev/null @@ -1,153 +0,0 @@ -renderScript() in ) and - * mounts an OLD Pageflow bundle into a BARE
in the body. - * The CURRENT (Inertia v2) client instead boots from the root element's - * `data-page` attribute — to switch, drop the legacy (its JSON-LD tag), - * which would terminate the inline window.initialPage script early — and the - * client has no use for server-rendered head HTML anyway. - */ -$seoHead = (string) ($props['seoHead'] ?? ''); -$bootPage = $seoHead === '' ? $FLOW_PAGE : new \Plugins\Pageflow\Http\PageflowPage( - component: $FLOW_PAGE->component, - props: array_diff_key($props, ['seoHead' => true]), - url: $FLOW_PAGE->url, - version: $FLOW_PAGE->version, - clearHistory: $FLOW_PAGE->clearHistory, - encryptHistory: $FLOW_PAGE->encryptHistory, -); - -// The `hkm ui` surface to boot + its Vite entry point (manifest key, relative to -// the frontend/ vite root). Surface: render()'s $FLOW_SURFACE → `surface` shared -// prop → VITE_SURFACE env → 'admin'. -$surface = (string) (($FLOW_SURFACE ?? '') ?: ($props['surface'] ?? (env('VITE_SURFACE') ?: 'admin'))); -// Entry: render()'s $FLOW_VITE_ENTRY override → `viteEntry` prop → surface convention. -$entry = (string) (($FLOW_VITE_ENTRY ?? null) ?: ($props['viteEntry'] ?? "src/surfaces/{$surface}/index.tsx")); - -// Cache-bust the fallback bundle with the same version the client checks (only -// used when ViteManifest is not enabled). -$assetVersion = rawurlencode($FLOW_PAGE->version); -?> - - - - - - - - - - - - - . */ ?> - - - - <?= htmlspecialchars($seoHead, ENT_QUOTES, 'UTF-8') ?> - - <?= $pageTitle ?> - - - - - - / - - - renderScript(); - ?> - - - above) and - * mounts React into this empty
. - * - * To switch to the CURRENT (Inertia v2) client, drop the legacy "; - } -} diff --git a/plugins/SiteSEO/Exceptions/SeoException.php b/plugins/SiteSEO/Exceptions/SeoException.php deleted file mode 100644 index 9d2da6d..0000000 --- a/plugins/SiteSEO/Exceptions/SeoException.php +++ /dev/null @@ -1,12 +0,0 @@ - - * @copyright Copyright (c) 2022 - present Hakeem Shamavu - * @license MIT License - */ -class SeoException extends Exception { } diff --git a/plugins/SiteSEO/Exceptions/SitemapException.php b/plugins/SiteSEO/Exceptions/SitemapException.php deleted file mode 100644 index e6627f1..0000000 --- a/plugins/SiteSEO/Exceptions/SitemapException.php +++ /dev/null @@ -1,11 +0,0 @@ - - * @copyright Copyright (c) 2022 - present Hakeem Shamavu - * @license MIT License - */ -class SitemapException extends SeoException { } diff --git a/plugins/SiteSEO/Helpers/Escape.php b/plugins/SiteSEO/Helpers/Escape.php deleted file mode 100644 index 087c8e2..0000000 --- a/plugins/SiteSEO/Helpers/Escape.php +++ /dev/null @@ -1,46 +0,0 @@ - - * @copyright Copyright (c) 2022 - present Hakeem Shamavu - * @license MIT License - */ -class Escape -{ - - public static $encoding = 'UTF-8'; - - public static function escape(string $text): string - { - return htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, static::$encoding); - } - - /** - * Escape url for sitemaps. - * - * @param string $url - * @return string - */ - public static function escapeUrl(string $url): string - { - $url = parse_url($url); - $url['path'] = $url['path'] ?? ''; - $url['query'] = $url['query'] ?? ''; - - if ($url['path'] !== '') { - $url['path'] = implode('/', array_map('rawurlencode', explode('/', $url['path']))); - } - - if ($url['query'] !== '') { - $url['query'] = "?{$url['query']}"; - } - - return str_replace( - ['&', "'", '"', '>', '<'], - ['&', ''', '"', '>', '<'], - $url['scheme'] . "://{$url['host']}{$url['path']}{$url['query']}" - ); - } -} \ No newline at end of file diff --git a/plugins/SiteSEO/Indexing.php b/plugins/SiteSEO/Indexing.php deleted file mode 100644 index e5259e3..0000000 --- a/plugins/SiteSEO/Indexing.php +++ /dev/null @@ -1,87 +0,0 @@ -host = $host; - $this->keys = $keys; - } - - /** - * Instant index single url. - * - * @param string $url - * @return array - */ - public function indexUrl(string $url): array - { - return $this->indexUrls([$url]); - } - - /** - * Instant index multiple urls. - * - * @param array $urls - * @return array - */ - public function indexUrls(array $urls): array - { - $accepted = []; - - foreach($this->keys as $engine => $key) - { - $accepted[$engine] = $this->index($engine, $key, $urls); - } - - return $accepted; - } - - /** - * Send index request to search engine. - * - * @param string $engine - * @param string $apiKey - * @param array $urls - * @return bool - */ - protected function index(string $engine, string $apiKey, array $urls): bool - { - $ch = curl_init("https://{$engine}/indexnow"); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['host' => $this->host, 'key' => $apiKey, 'urlList' => $urls])); - curl_setopt($ch, CURLOPT_HTTPHEADER, ['content-type: application/json']); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - return $code >= 200 && $code < 300; - } -} diff --git a/plugins/SiteSEO/Infrastructure/Gateways/SearchEngineGateway.php b/plugins/SiteSEO/Infrastructure/Gateways/SearchEngineGateway.php deleted file mode 100644 index 0751c6c..0000000 --- a/plugins/SiteSEO/Infrastructure/Gateways/SearchEngineGateway.php +++ /dev/null @@ -1,127 +0,0 @@ - $keys Engine host => API key. - * @param list $urls - * @return array Engine host => accepted (2xx). - */ - public function submitIndexNow(string $host, array $keys, array $urls): array - { - $accepted = []; - - foreach ($keys as $engine => $key) { - try { - $response = $this->http->post("https://{$engine}/indexnow", [ - 'headers' => ['Content-Type' => 'application/json'], - 'json' => [ - 'host' => $host, - 'key' => $key, - 'urlList' => array_values($urls), - ], - ]); - - $accepted[$engine] = $response->ok(); - } catch (\Throwable $e) { - throw new GatewayException( - "IndexNow submission to [{$engine}] failed.", - layer: 'gateway.seo.indexnow', - context: ['engine' => $engine, 'urls' => count($urls)], - previous: $e, - ); - } - } - - return $accepted; - } - - /** - * Submit ONE batch of URLs to a single IndexNow endpoint (modern protocol). - * - * Includes `keyLocation` so the engine can verify domain ownership against - * the hosted key file instead of guessing `{key}.txt` at the root. A single - * submission to api.indexnow.org propagates to all participating engines - * (Bing, Yandex, Seznam, Naver, …). The IndexNow cap is 10 000 URLs per - * request — the caller (service) chunks to honour it. - * - * @param list $urls Absolute URLs, all on $host. - * @return bool Whether the endpoint accepted the batch (2xx). - */ - public function indexNowBatch(string $endpoint, string $host, string $key, string $keyLocation, array $urls): bool - { - try { - $response = $this->http->post($endpoint, [ - 'headers' => ['Content-Type' => 'application/json; charset=utf-8'], - 'json' => [ - 'host' => $host, - 'key' => $key, - 'keyLocation' => $keyLocation, - 'urlList' => array_values($urls), - ], - ]); - - // IndexNow: 200/202 accepted, 422 = invalid URLs, 403 = key not - // verified, 429 = too many. Only 2xx counts as accepted. - return $response->ok(); - } catch (\Throwable $e) { - throw new GatewayException( - "IndexNow submission to [{$endpoint}] failed.", - layer: 'gateway.seo.indexnow', - context: ['endpoint' => $endpoint, 'urls' => count($urls)], - previous: $e, - ); - } - } - - /** - * Ping search engines with an updated sitemap URL. - * - * @param list $extraEngines Additional engine base URLs. - */ - public function pingSitemap(string $sitemapUrl, array $extraEngines = []): void - { - $engines = array_unique([...self::DEFAULT_PING_ENGINES, ...$extraEngines]); - - foreach ($engines as $engine) { - try { - $this->http->get("{$engine}/ping", ['sitemap' => $sitemapUrl]); - } catch (\Throwable $e) { - throw new GatewayException( - "Sitemap ping to [{$engine}] failed.", - layer: 'gateway.seo.ping', - context: ['engine' => $engine, 'sitemap' => $sitemapUrl], - previous: $e, - ); - } - } - } -} diff --git a/plugins/SiteSEO/Infrastructure/Http/SeoController.php b/plugins/SiteSEO/Infrastructure/Http/SeoController.php deleted file mode 100644 index ef959de..0000000 --- a/plugins/SiteSEO/Infrastructure/Http/SeoController.php +++ /dev/null @@ -1,62 +0,0 @@ -seo->robots()->getContent()) - ->withHeader('Content-Type', 'text/plain; charset=UTF-8'); - } - - /** Notify search engines that a sitemap changed. */ - public function ping(Request $request): Response - { - $sitemap = (string) $request->input('sitemap', ''); - - if ($sitemap === '') { - return $this->unprocessable(['sitemap' => 'A sitemap URL is required.']); - } - - $this->seo->pingSitemap($sitemap, (array) $request->input('engines', [])); - - return $this->accepted(['pinged' => $sitemap]); - } - - /** Submit URLs to IndexNow for instant indexing. */ - public function indexNow(Request $request): Response - { - $host = (string) $request->input('host', ''); - $keys = (array) $request->input('keys', []); - $urls = (array) $request->input('urls', []); - - if ($host === '' || $keys === [] || $urls === []) { - return $this->unprocessable([ - 'host' => 'host, keys and urls are all required.', - ]); - } - - return $this->ok(['accepted' => $this->seo->submitUrls($host, $keys, $urls)]); - } -} diff --git a/plugins/SiteSEO/Interfaces/SchemaInterface.php b/plugins/SiteSEO/Interfaces/SchemaInterface.php deleted file mode 100644 index 82a4918..0000000 --- a/plugins/SiteSEO/Interfaces/SchemaInterface.php +++ /dev/null @@ -1,13 +0,0 @@ - - * @copyright Copyright (c) 2022 - present Hakeem Shamavu - * @license MIT License - */ -interface SchemaInterface extends SeoInterface, \JsonSerializable -{ - public function __toString(): string; -} diff --git a/plugins/SiteSEO/Interfaces/SeoInterface.php b/plugins/SiteSEO/Interfaces/SeoInterface.php deleted file mode 100644 index bbe6cf5..0000000 --- a/plugins/SiteSEO/Interfaces/SeoInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -is_public(); - }else{ - return $rb->is_private(); - } - } - - - public static function article(string|null $title = null): Article - { - return Article::make($title); - } - - public static function book(string|null $title = null): Book - { - return Book::make($title); - } - - public static function profile(string|null $title = null): Profile - { - return Profile::make($title); - } - - public static function movie(string|null $title = null): Movie - { - return Movie::make($title); - } - - public static function tvShow(string|null $title = null): TvShow - { - return TvShow::make($title); - } - - public static function episode(string|null $title = null): Episode - { - return Episode::make($title); - } - - public static function other(string|null $title = null): Other - { - return Other::make($title); - } - - public static function album(string|null $title = null): Album - { - return Album::make($title); - } - - public static function song(string|null $title = null): Song - { - return Song::make($title); - } - - public static function playlist(string|null $title = null): Playlist - { - return Playlist::make($title); - } - - public static function radioStation(string|null $title = null): RadioStation - { - return RadioStation::make($title); - } -} diff --git a/plugins/SiteSEO/Ping.php b/plugins/SiteSEO/Ping.php deleted file mode 100644 index dc156d7..0000000 --- a/plugins/SiteSEO/Ping.php +++ /dev/null @@ -1,68 +0,0 @@ -engines = array_unique(array_merge($this->engines, $append)); - } - } - - /** - * Send sitemap url to registred engines - * - * @param string $sitemapUrl - * @return void - */ - public function send(string $sitemapUrl): void - { - foreach ($this->engines as $engine) - { - $this->inform($engine, $sitemapUrl); - } - } - - /** - * Inform search engine - * - * @param string $engine - * @param string $url - * @return void - */ - public function inform(string $engine, string $url): void - { - $req = curl_init("{$engine}/ping?sitemap={$url}"); - curl_setopt($req, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($req, CURLOPT_SSL_VERIFYPEER, false); - curl_setopt($req, CURLOPT_RETURNTRANSFER, 1); - curl_exec($req); - curl_close($req); - } -} diff --git a/plugins/SiteSEO/Property.php b/plugins/SiteSEO/Property.php deleted file mode 100644 index d6c0eb6..0000000 --- a/plugins/SiteSEO/Property.php +++ /dev/null @@ -1,34 +0,0 @@ -prefix = $prefix; - $this->property = $property; - $this->content = $content; - } - - public static function make(string $prefix, string $property, string $content) - { - return new static($prefix, $property, $content); - } - - public function __toString(): string - { - $content = str_replace('"', '"', $this->content); - - return "prefix}:{$this->property}\" content=\"{$content}\">"; - } -} diff --git a/plugins/SiteSEO/Provider.php b/plugins/SiteSEO/Provider.php deleted file mode 100644 index 33be141..0000000 --- a/plugins/SiteSEO/Provider.php +++ /dev/null @@ -1,82 +0,0 @@ - */ - public function requires(): array - { - return ['http.client']; - } - - /** @return list */ - public function exposes(): array - { - return [SeoServiceContract::class]; - } - - public function register(ModuleContainer $container): void - { - // Internal — outbound search-engine transport. Not resolvable outside this module. - $container->bindInternal(SearchEngineGateway::class, static fn(ModuleContainer $c) => - new SearchEngineGateway( - $c->make(HttpClientPort::class), - ) - ); - - // Published — the only surface other modules / project routes may resolve. - $container->bind(SeoServiceContract::class, static fn(ModuleContainer $c) => - new SeoService( - engines: $c->make(SearchEngineGateway::class), - ) - ); - - // Background job — resolved by the WorkerLoop from this module's scope, - // so it autowires the (internal) gateway. - $container->bind(IndexNowJob::class, static fn(ModuleContainer $c) => - new IndexNowJob( - $c->make(SearchEngineGateway::class), - ) - ); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Index-on-publish: when anything emits seo.url_published, enqueue an - // IndexNow submission. The listener is resolved from the CoreContainer, - // where the project binds it with a QueuePort. - $events->subscribe( - (new UrlPublishedIntegrationEvent(''))->name(), - EnqueueIndexNowListener::class, - ); - } -} diff --git a/plugins/SiteSEO/RobotsTxtEditor.php b/plugins/SiteSEO/RobotsTxtEditor.php deleted file mode 100644 index bd79951..0000000 --- a/plugins/SiteSEO/RobotsTxtEditor.php +++ /dev/null @@ -1,379 +0,0 @@ -filePath = ABSPATH . 'robots.txt'; - $this->encoding = $encoding; - - $this_ = $this; - execute_with_temp_permission(ABSPATH,function() use ($this_){ - // Ensure the robots.txt file exists - if (!file_exists($this_->filePath)) { - $this_->createDefaultRobotsTxt(); - } - }); - - } - - /** - * Create a default robots.txt file with a basic "allow all" rule. - * - * This method creates a default robots.txt file with an allow-all rule - * for all user agents, along with a reference to the XML sitemap and host URL. - */ - private function createDefaultRobotsTxt() - { - check_loader_function_and_include_file('is_site_installed', 'Install.IS_Installation'); - if (is_site_installed()) { - - $default_rules = generateRobotsTxtContentArray(); - - $this->contents = ""; - $this->reading = true; - $robots_generate = get_option('robots_generate', []); - check_loader_function_and_include_file('current_time', 'Utility.Date'); - $robots_generate['created_at'] = current_time('mysql', 1); - update_option('robots_generate', $robots_generate); - - - $this->updateRobotsTxt($default_rules); - } - } - - /** - * Get rules by specific bot (user-agent) - * Use $userAgent = NULL to get all rules for all user-agents grouped by user-agent. User-agents will return in lower case. - * Use $userAgent = '*' to get common rules. - * Use $userAgent = 'YandexBot' to get rules for user-agent 'YandexBot'. - * - * @param string $userAgent - * @return array - */ - public function getRules($userAgent = null) - { - if (is_null($userAgent)) { - //return all rules - return $this->rules; - } else { - if (isset($this->rules[$userAgent])) { - return $this->rules[$userAgent]; - } else { - return []; - } - } - } - - /** - * Get sitemaps links. - * Sitemap always relates to all user-agents and return in rules with user-agent "*" - * - * @return array - */ - public function getSitemaps() - { - $rules = $this->getRules(self::USER_AGENT_ALL); - if (!empty($rules[self::DIRECTIVE_SITEMAP])) { - return $rules[self::DIRECTIVE_SITEMAP]; - } - - return []; - } - - /** - * Return original robots.txt content - * - * @return string - */ - public function getContent() - { - return $this->contents; - } - - /** - * Read and parse the contents of the robots.txt file. - * - * @return array An associative array representing the parsed robots.txt file. - * - * This method reads the contents of the robots.txt file, parses it into - * an associative array of directives, and returns the result. - */ - public function readRobotsTxt() - { - $this->reading = true; - if (empty($this->contents)) { - // Read the contents of the robots.txt file - $this->contents = file_get_contents($this->filePath); - } - - if (empty($this->contents)) return []; - - // Parse the robots.txt content into an associative array - $lines = explode("\n", $this->contents); - $parsedRobotsTxt = []; - $currentUserAgent = ''; - - foreach ($lines as $line) { - $line = trim($line); - - if (empty($line)) { - continue; - } - - if (strpos($line, self::DIRECTIVE_USERAGENT . ':') === 0) { - $currentUserAgent = substr($line, strlen(self::DIRECTIVE_USERAGENT . ':')); - $parsedRobotsTxt[$currentUserAgent] = [ - self::DIRECTIVE_CRAWL_DELAY => $currentUserAgent == self::USER_AGENT_ALL ? self::CRAWL_DELAY_SECONDS : 5 - ]; - } else { - - $directive = $this->getDerictiveOnLine($line); - if ($directive) { - $agents = $parsedRobotsTxt[$currentUserAgent]; - if (!isset($agents[$directive])) { - $agents[$directive] = []; - } - $agents[$directive][] = substr($line, strlen($directive . ':')); - $parsedRobotsTxt[$currentUserAgent] = $agents; - } - } - } - - $this->rules = $parsedRobotsTxt; - return $this->rules; - } - - /** - * Return array of supported directives - * - * @return array - */ - protected function getAllowedDirectives() - { - return [ - self::DIRECTIVE_NOINDEX, - self::DIRECTIVE_ALLOW, - self::DIRECTIVE_DISALLOW, - self::DIRECTIVE_HOST, - self::DIRECTIVE_SITEMAP, - self::DIRECTIVE_USERAGENT, - self::DIRECTIVE_CRAWL_DELAY, - self::DIRECTIVE_CLEAN_PARAM, - ]; - } - - protected function getDerictiveOnLine(string $line): string|false - { - $directives = $this->getAllowedDirectives(); - foreach ($directives as $directive) { - if (strpos($line, $directive . ':') === 0) { - return $directive; - } - } - - return false; - } - - /** - * Update the robots.txt file with new rules. - * - * @param array $rules Associative array of rules to update the robots.txt file. - * - * This method updates the robots.txt file with new rules provided in the $rules parameter. - * It merges and updates existing rules with the new ones, ensuring consistency and accuracy. - */ - public function updateRobotsTxt($rules = []) - { - - // Ensure robots.txt file is read - if (!$this->reading) { - $this->readRobotsTxt(); - } - - - foreach ($rules as $user_agent => $value) { - if (isset($this->rules[$user_agent])) { - // Merge new directives with existing ones - $directives = (array) $this->rules[$user_agent]; - } else { - $directives = []; - } - - // Merge or update disallowed paths - if (isset($value[self::DIRECTIVE_DISALLOW])) { - $allowed = $directives[self::DIRECTIVE_ALLOW]??[]; - if (!empty($allowed)) { - check_function_and_include_file('removeArray', 'Utility.array'); - - $removed_directives = apply_filters('site_seo_robots_' . strtolower(self::DIRECTIVE_DISALLOW), $value[self::DIRECTIVE_DISALLOW]??[], $user_agent); - - removeArrays($allowed, $removed_directives); - - do_action('site_seo_robots_done_' . strtolower(self::DIRECTIVE_DISALLOW), $removed_directives, $user_agent); - } - - $directives[self::DIRECTIVE_ALLOW] = $allowed; - - check_function_and_include_file('array_merge_unique', 'Utility.array'); - $directives[self::DIRECTIVE_DISALLOW] = array_merge_unique($directives[self::DIRECTIVE_DISALLOW]??[], $value[self::DIRECTIVE_DISALLOW]??[]); - } - // Merge or update allowed paths - if (isset($value[self::DIRECTIVE_ALLOW])) { - $disallowed = $directives[self::DIRECTIVE_DISALLOW]??[]; - if (!empty($disallowed)) { - check_function_and_include_file('removeArray', 'Utility.array'); - $added_directives = apply_filters('site_seo_robots_' . strtolower(self::DIRECTIVE_ALLOW), $value[self::DIRECTIVE_ALLOW]??[], $user_agent); - - removeArrays($disallowed, $value[self::DIRECTIVE_ALLOW]); - do_action('site_seo_robots_done_' . strtolower(self::DIRECTIVE_ALLOW), $added_directives, $user_agent); - } - - $directives[self::DIRECTIVE_DISALLOW] = $disallowed; - - check_function_and_include_file('array_merge_unique', 'Utility.array'); - $directives[self::DIRECTIVE_ALLOW] = array_merge_unique($directives[self::DIRECTIVE_ALLOW], $value[self::DIRECTIVE_ALLOW]??[]); - } - - // Merge or update other directives - foreach ([self::DIRECTIVE_HOST, self::DIRECTIVE_SITEMAP, self::DIRECTIVE_CRAWL_DELAY, self::DIRECTIVE_CLEAN_PARAM] as $directive) { - if (isset($value[$directive])) { - check_function_and_include_file('array_merge_unique', 'Utility.array'); - - $directives[$directive] = array_merge_unique( - $directives[$directive] ?? [], - (array) $value[$directive] - ); - do_action('site_seo_robots_done_' . strtolower($directive), $directives[$directive]??[], $user_agent); - } - } - - $this->rules[$user_agent] = $directives; - } - - - - // Initialize new content for robots.txt - $newContent = ''; - - // Get allowed directives - $allowedDirectives = $this->getAllowedDirectives(); - - // Iterate through rules - foreach ($this->rules as $userAgent => $directives) { - // Append user agent directive - $newContent .= "\n".self::DIRECTIVE_USERAGENT . ": $userAgent\n"; - - // Filter directives to include only allowed ones - $filteredDirectives = array_intersect_key($directives, array_flip($allowedDirectives)); - - // Iterate through filtered directives - foreach ($filteredDirectives as $directive => $values) { - // Convert values to array - $values = (array) $values; - - // Append directive and values to new content - $newContent .= implode("", array_map(function ($value) use ($directive) { - return "$directive: $value\n"; - }, $values)); - } - } - - $robots_generate = get_option('robots_generate', []); - - $robots_generate['status'] = true; - $robots_generate['count'] = count($this->rules); - $robots_generate['rules'] = $this->rules; - - check_loader_function_and_include_file('current_time', 'Utility.Date'); - $robots_generate['update_at'] = current_time('mysql', 1); - - update_option('robots_generate', $robots_generate); - - $this->contents = $newContent; - // Write the new content to the robots.txt file - file_put_contents($this->filePath, $newContent); - } - - /** - * Add rules for a specific user agent to the robots.txt file. - * - * @param string $userAgent The user agent for which to add rules. - * @param array $rules An associative array of rules to add for the specified user agent. - * - * This method adds rules for a specific user agent to the robots.txt file. - * It allows for fine-grained control over crawling behavior for different bots. - */ - public function addRulesForUserAgent($userAgent, $rules = []) - { - - - $rules_ = []; - // Set allow rules for specified user agent - if (empty($rules)) { - $rules_[$userAgent] = [ - self::DIRECTIVE_DISALLOW => [], - self::DIRECTIVE_CRAWL_DELAY => $userAgent == self::USER_AGENT_ALL ? self::CRAWL_DELAY_SECONDS : 5 - ]; - } else { - $rules[self::DIRECTIVE_CRAWL_DELAY] = $userAgent == self::USER_AGENT_ALL ? self::CRAWL_DELAY_SECONDS : 5; - $rules_[$userAgent] = $rules; - } - - // Update robots.txt with new rules - $this->updateRobotsTxt($rules_); - } -} diff --git a/plugins/SiteSEO/RobotsTxtValidator.php b/plugins/SiteSEO/RobotsTxtValidator.php deleted file mode 100644 index 2c6332a..0000000 --- a/plugins/SiteSEO/RobotsTxtValidator.php +++ /dev/null @@ -1,224 +0,0 @@ -rules = $rules; - } - - /** - * Return true if url is allow to crawl by robots.txt rules otherwise false - * - * @param string $url Should be relative - * @param string $userAgent - * @return bool - */ - public function isUrlAllow($url, $userAgent = '*') - { - $relativeUrl = $this->getRelativeUrl($url); - - $orderedDirectives = $this->getOrderedDirectivesByUserAgent($userAgent); - // if find no directive for particular User Agent then find for all '*' - if (count($orderedDirectives) == 0 && $userAgent != '*') { - $orderedDirectives = $this->getOrderedDirectivesByUserAgent('*'); - } - - // if has not allow rules we can determine when url disallowed even on one coincidence - just to do it faster. - $hasAllowDirectives = true; - foreach ($orderedDirectives as $directiveRow) { - if ($directiveRow['directive'] == RobotsTxtEditor::DIRECTIVE_ALLOW) { - $hasAllowDirectives = true; - break; - } - } - - $isAllow = true; - foreach ($orderedDirectives as $directiveRow) { - if (!in_array($directiveRow['directive'], array(RobotsTxtEditor::DIRECTIVE_ALLOW, RobotsTxtEditor::DIRECTIVE_DISALLOW))) { - continue; - } - - if (preg_match($directiveRow['rule_regexp'], $relativeUrl)) { - if ($directiveRow['directive'] == RobotsTxtEditor::DIRECTIVE_ALLOW) { - $isAllow = true; - } else { - if (!$hasAllowDirectives) { - return false; - } - - $isAllow = false; - } - } - } - - return $isAllow; - } - - /** - * Return true if url is disallow to crawl by robots.txt rules otherwise false - * - * @param string $url - * @param string $userAgent - * @return bool - */ - public function isUrlDisallow($url, $userAgent = '*') - { - return !$this->isUrlAllow($url, $userAgent); - } - - /** - * Get array of ordered by length rules from allow and disallow directives by specific user-agent - * If you have already stored robots.txt rules into database, you can use query like this to fetch ordered rules: - * mysql> SELECT directive,value FROM robots_txt where site_id = ?d and directive IN (RobotsTxtEditor::DIRECTIVE_ALLOW,'disallow) AND user_agent = ? ORDER BY CHAR_LENGTH(value) ASC; - * - * @param string $userAgent - * @return array - */ - private function getOrderedDirectivesByUserAgent($userAgent) - { - if (!isset($this->orderedDirectivesCache[$userAgent])) { - if (!empty($this->rules[$userAgent])) { - //put data to execution cache - $this->orderedDirectivesCache[$userAgent] = $this->orderDirectives($this->rules[$userAgent]); - } else { - $this->orderedDirectivesCache[$userAgent] = array(); - } - } - - return $this->orderedDirectivesCache[$userAgent]; - } - - /** - * Order directives by rule char length - * - * @param array $rules - * @return array $directives - */ - private function orderDirectives(array $rules) - { - $directives = array(); - - $allowRules = !empty($rules[RobotsTxtEditor::DIRECTIVE_ALLOW]) ? $rules[RobotsTxtEditor::DIRECTIVE_ALLOW] : array(); - $disallowRules = !empty($rules[RobotsTxtEditor::DIRECTIVE_DISALLOW]) ? $rules[RobotsTxtEditor::DIRECTIVE_DISALLOW] : array(); - - foreach ($allowRules as $rule) { - $directives[] = array( - 'directive' => RobotsTxtEditor::DIRECTIVE_ALLOW, - 'rule' => $rule, - 'rule_regexp' => $this->prepareRegexpRule($rule), - ); - } - - foreach ($disallowRules as $rule) { - $directives[] = array( - 'directive' => RobotsTxtEditor::DIRECTIVE_DISALLOW, - 'rule' => $rule, - 'rule_regexp' => $this->prepareRegexpRule($rule), - ); - } - - usort($directives, function ($row1, $row2) { - return mb_strlen($row1['rule']) > mb_strlen($row2['rule']) ? 1 : -1; - }); - - return $directives; - } - - /** - * Always returns relative url without domain which start from "/", e.g.: - * - * http://example.com/test -> /test - * https://example.com/test/path -> /test/path - * /test/any/path -> /test/any/path - * http://example.com -> / - * / -> / - * /some/path -> /some/path - * - * @param string $url - * @return string - * @throws \InvalidArgumentException - */ - private function getRelativeUrl($url) - { - if (!$url) { - throw new \InvalidArgumentException('Url should not be empty'); - } - - if (!preg_match('!^https?://!i', $url)) { - if (empty($url[0]) || $url[0] !== '/') { - throw new \InvalidArgumentException('Url should start from "/" or has protocol with domain, got ' . $url); - } else { - return $url; - } - } - - $parsedUrl = parse_url($url); - if (!$parsedUrl) { - throw new \InvalidArgumentException('Can\'t parse url, probably url is invalid'); - } - if (isset($parsedUrl['host']) && !isset($parsedUrl['path'])) { - return '/'; - } - // make url relative - return ((isset($parsedUrl['path']) ? "{$parsedUrl['path']}" : '') . - (isset($parsedUrl['query']) ? "?{$parsedUrl['query']}" : '')); - } - - /** - * Convert robots.txt rule to php RegExp - * - * @param string $ruleValue - * @return string - */ - private static function prepareRegexpRule($ruleValue) - { - $replacementsBeforeQuote = [ - '*' => '_ASTERISK_WILDCARD_', - '$' => '_DOLLAR_WILDCARD_', - ]; - - $replacementsAfterQuote = [ - '_ASTERISK_WILDCARD_' => '.*', - '_DOLLAR_WILDCARD_' => '$', - ]; - - $regexp = str_replace( - array_keys($replacementsBeforeQuote), - array_values($replacementsBeforeQuote), - $ruleValue - ); - - $regexp = preg_quote($regexp, '/'); - - $regexp = str_replace( - array_keys($replacementsAfterQuote), - array_values($replacementsAfterQuote), - $regexp - ); - - return '/^' . $regexp . '/'; - } -} \ No newline at end of file diff --git a/plugins/SiteSEO/Schema.php b/plugins/SiteSEO/Schema.php deleted file mode 100644 index 85c5ce0..0000000 --- a/plugins/SiteSEO/Schema.php +++ /dev/null @@ -1,87 +0,0 @@ -things = $things; - } - - - /** - * Add schema item to the graph. - * - * @param SchemaInterface $thing - */ - public function add(SchemaInterface $thing): SchemaInterface - { - $this->things[] = $thing; - return $this; - } - - /** - * Get data as array - * - * @return array - */ - public function jsonSerialize(): array - { - // A single node renders flat ({ "@context", "@type", … }); multiple nodes - // render as a connected "@graph" — the shape Google prefers for rich - // results, where nodes cross-reference each other by "@id". (Previously - // only things[0] was serialized, so add() silently dropped every extra - // node and no @graph was ever produced.) - if (count($this->things) <= 1) { - $json = [ - '@context' => 'https://schema.org', - ...($this->things[0] ?? new \Plugins\SiteSEO\Schema\Thing('Thing'))->jsonSerialize(), - ]; - - ksort($json); - - return $json; - } - - return [ - '@context' => 'https://schema.org', - '@graph' => array_map( - static fn(SchemaInterface $thing): array => $thing->jsonSerialize(), - $this->things, - ), - ]; - } - - - /** - * Serialize root schema - * - * @return string - */ - public function __toString(): string - { - // JSON_HEX_TAG: with slashes unescaped, a "" inside any - // user-sourced string value would otherwise close the JSON-LD block - // and execute markup (stored XSS on public pages). - return ''; - } - -} \ No newline at end of file diff --git a/plugins/SiteSEO/Schema/Thing.php b/plugins/SiteSEO/Schema/Thing.php deleted file mode 100644 index e78524a..0000000 --- a/plugins/SiteSEO/Schema/Thing.php +++ /dev/null @@ -1,63 +0,0 @@ -context = $data["context"]; - unset($data['context']); - } - $this->data = $data; - $this->type = $type; - } - - public function __get(string $name) - { - return $this->data[$name] ?? null; - } - - - public function __set(string $name, $value) - { - $this->data[$name] = $value; - } - - public function jsonSerialize(): array - { - $data = [ - '@type' => $this->type, - ]; - - if ($this->context !== null) { - $data['@context'] = $this->context; - } - - $json = array_merge($this->data, $data); - ksort($json); - - return $json; - } - - public function __toString(): string - { - return ''; - } -} diff --git a/plugins/SiteSEO/Schema/Things/ContactPoint.php b/plugins/SiteSEO/Schema/Things/ContactPoint.php deleted file mode 100644 index bf6611a..0000000 --- a/plugins/SiteSEO/Schema/Things/ContactPoint.php +++ /dev/null @@ -1,25 +0,0 @@ -data['telephone']=$value; - return $this; - } - - public function setContactType(string $value) :self - { - $this->data['contactType']=$value; - return $this; - } -} \ No newline at end of file diff --git a/plugins/SiteSEO/Schema/Things/Offer.php b/plugins/SiteSEO/Schema/Things/Offer.php deleted file mode 100644 index 675ed82..0000000 --- a/plugins/SiteSEO/Schema/Things/Offer.php +++ /dev/null @@ -1,37 +0,0 @@ -data['availability']=$value; - return $this; - } - - public function setPriceCurrency(string $value) :self - { - $this->data['priceCurrency']=$value; - return $this; - } - - public function setPrice(float $value) :self - { - $this->data['price']=$value; - return $this; - } - - public function setUrl(string $value) :self - { - $this->data['url']=$value; - return $this; - } -} \ No newline at end of file diff --git a/plugins/SiteSEO/Schema/Things/Organization.php b/plugins/SiteSEO/Schema/Things/Organization.php deleted file mode 100644 index d57bd57..0000000 --- a/plugins/SiteSEO/Schema/Things/Organization.php +++ /dev/null @@ -1,31 +0,0 @@ -data['url']=$value; - return $this; - } - - public function setLogo(string $value) :self - { - $this->data['logo']=$value; - return $this; - } - - public function setContactPoint(ContactPoint $value) :self - { - $this->data['contactPoint']=$value; - return $this; - } -} \ No newline at end of file diff --git a/plugins/SiteSEO/Schema/Things/Product.php b/plugins/SiteSEO/Schema/Things/Product.php deleted file mode 100644 index 852a0bf..0000000 --- a/plugins/SiteSEO/Schema/Things/Product.php +++ /dev/null @@ -1,43 +0,0 @@ -data['name']=$value; - return $this; - } - - public function setSku(string $value) :self - { - $this->data['sku']=$value; - return $this; - } - - public function setImage(string $value) :self - { - $this->data['image']=$value; - return $this; - } - - public function setDescription(string $value) :self - { - $this->data['description']=$value; - return $this; - } - - public function setOffers(Offer $value) :self - { - $this->data['offers']=$value; - return $this; - } -} \ No newline at end of file diff --git a/plugins/SiteSEO/Schema/Things/WebPage.php b/plugins/SiteSEO/Schema/Things/WebPage.php deleted file mode 100644 index 8788012..0000000 --- a/plugins/SiteSEO/Schema/Things/WebPage.php +++ /dev/null @@ -1,31 +0,0 @@ -data['@id']=$value; - return $this; - } - - public function setUrl(string $value) :self - { - $this->data['url']=$value; - return $this; - } - - public function setName(string $value) :self - { - $this->data['name']=$value; - return $this; - } -} \ No newline at end of file diff --git a/plugins/SiteSEO/Sitemap.php b/plugins/SiteSEO/Sitemap.php deleted file mode 100644 index db849a3..0000000 --- a/plugins/SiteSEO/Sitemap.php +++ /dev/null @@ -1,420 +0,0 @@ - null, - 'index_name' => 'sitemap.xml', - 'sitemaps_url' => null, - ]; - - /** - * Sitemap files - * @var array - */ - protected $sitemaps = []; - - /** - * Sitemaps domain name - * @var string - */ - protected $domain; - - - /** - * Initialize new sitemap builder - * - * @param string $domain The domain name only - * @param array $options - */ - public function __construct(string $domain, array $options = null) - { - $this->domain = $domain; - - if ($options !== null) { - $this->setOptions($options); - } - } - - /** - * Set builer options - * - * @param array $options - * @return SitemapIndexInterface - */ - public function setOptions(array $options): SitemapIndexInterface - { - $this->options = array_merge($this->options, $options); - return $this; - } - - /** - * Get all sitemap options - * - * @return array - */ - public function getOptions(): array - { - return $this->options; - } - - /** - * Set save path - * - * @param string $path - * @return SitemapIndexInterface - */ - public function setSavePath(string $path): SitemapIndexInterface - { - $this->options['save_path'] = $path; - return $this; - } - - /** - * Get save path - * - * @return null|string - */ - public function getSavePath(): ?string - { - return $this->options['save_path']; - } - - /** - * Set index name - * - * @param string $name - * @return SitemapIndexInterface - */ - public function setIndexName(string $name): SitemapIndexInterface - { - $this->options['index_name'] = $name; - return $this; - } - - /** - * Get Index name - * - * @return string - */ - public function getIndexName(): string - { - return $this->options['index_name']; - } - - /** - * Set sitemaps url - * - * @param string $url - * @return SitemapIndexInterface - */ - public function setSitemapsUrl(string $url): SitemapIndexInterface - { - $this->options['sitemaps_url'] = $url; - return $this; - } - - /** - * Get sitemaps url - * - * @return null|string - */ - public function getSitemapsUrl(): ?string - { - return $this->options['sitemaps_url'] ?? $this->domain; - } - - /** - * Get sitemaps domain - * - * @return string - */ - public function getDomain(): string - { - return $this->domain; - } - - /** - * Set sitemaps to a path - * - * @param string $path - * @return bool - */ - public function saveTo(string $path): bool - { - return SitemapIndex::build( - $this->getIndexName(), $path, $this->getSitemapsUrl(), $this->sitemaps - ); - } - - /** - * {@method saveTo} by pre defined save_path option - * - * @param string $path - * @return bool - */ - public function save(): bool - { - if (is_string($this->options['save_path']) === false) { - - throw new SitemapException('Invalid or missing save_path option'); - } - - $re = $this->saveTo($this->options['save_path']); - if($re){ - // Save the XSL stylesheet - $xslContent = $this->generateXsl(); - - $site_xsl = rtrim($this->options['save_path'],'/').'/sitemaps_xsl.xsl'; - - file_put_contents($site_xsl, $xslContent); - } - - return $re; - } - - public function generateXsl() { - $sitemap_url = site_url('/sitemap.xml'); - return << - - - - - - XML Sitemaps - - - - -
-

XML Sitemaps

-

- Index sitemaps -

-

This XML Sitemap Index file contains sitemaps.

-

This XML Sitemap contains URL(s).

-
-
URL
-
Last update
-
    -
  • - - - - - - - - - -
  • -
-
    -
  • - - - - - - - - - -
  • -
-
    -
  • - - - - - - - - - -
  • -
-
-
- - - - -XSL; - } - - - /** - * Generate sitemaps - * - * @param SitemapBuilderInterface $builder - * @param array $options - * @param callable $func - * @return SitemapIndexInterface - */ - public function build(SitemapBuilderInterface $builder, array $options, callable $func): SitemapIndexInterface - { - $name = $options['name']; - - if (isset($this->sitemaps[$name])) { - throw new SitemapException("The sitemap {$name} already registred!"); - } - - // Generate urls. - call_user_func_array($func, [$builder]); - - return $this->buildTemp($name, $builder); - } - - /** - * Sitemaps generator - * - * @param string $builder - * @param array $args - * @return SitemapIndexInterface - */ - public function __call(string $builder, array $args): SitemapIndexInterface - { - if (class_exists($builder = '\Plugins\SiteSEO\Sitemap\\' . ucfirst($builder) . 'Builder')) { - - if (count($args) !== 2) { - - throw new SitemapException("Invalid {$builder} arguments"); - - } elseif (is_string($args[0])) { - - $args[0] = ['name' => $args[0]]; - } - - if (isset($args[0]['name']) === false) { - - throw new SitemapException("Sitemap name is required for {$builder}"); - } - - return $this->build(new $builder($this->domain, $args[0]), ...$args); - } - - throw new SitemapException("Sitemap builder {$builder} not exists"); - } - - /** - * Build registred sitemap and save it on temp - * - * @param string $name - * @param SitemapBuilderInterface $builder - * @return SitemapIndexInterface - */ - protected function buildTemp(string $name, SitemapBuilderInterface $builder): SitemapIndexInterface - { - $this->sitemaps[$name] = $builder->saveTemp(); - return $this; - } -} diff --git a/plugins/SiteSEO/Sitemap/LinksBuilder.php b/plugins/SiteSEO/Sitemap/LinksBuilder.php deleted file mode 100644 index abd510f..0000000 --- a/plugins/SiteSEO/Sitemap/LinksBuilder.php +++ /dev/null @@ -1,10 +0,0 @@ - null, 'lang' => null]; - - /** - * Initialize NewsBuilder - * - * @param string $domain - * @param array|null $options - * @param string $ns - */ - public function __construct(string $domain, ?array $options = null, string $ns = '') - { - parent::__construct($domain, $options, $ns .' xmlns:news="'. static::NEWS_NS . '"'); - } - - - /** - * Set dafault publication - * - * @param string $name - * @param string $lang - * @return SitemapBuilderInterface - */ - public function setPublication(string $name, string $lang): SitemapBuilderInterface - { - $this->publication = - [ - 'name' => $name, - 'lang' => $lang - ]; - - return $this; - } - - /** - * Get publication - * - * @return array - */ - public function getPublication(): array - { - return $this->publication; - } - - - /** - * Set a news (Fake news not allowed ^_~) - * - * @param array $options - * @return SitemapBuilderInterface - */ - public function news(array $options): SitemapBuilderInterface - { - $options['name'] = $options['name'] ?? $this->publication['name']; - $options['language'] = $options['language'] ?? $this->publication['lang']; - - if (isset($options['name'], $options['language'], $options['publication_date'], $options['title']) === false) { - throw new SitemapException("News map require: name, language, publication_date and title"); - } - - $this->url['news'] = $options; - - return $this; - } - -} diff --git a/plugins/SiteSEO/Sitemap/SitemapBuilder.php b/plugins/SiteSEO/Sitemap/SitemapBuilder.php deleted file mode 100644 index 9a0b01d..0000000 --- a/plugins/SiteSEO/Sitemap/SitemapBuilder.php +++ /dev/null @@ -1,389 +0,0 @@ - ['thumbnail_loc', 'title', 'description'], - 'freq' => ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'] - ]; - - /** - * Url tag - * @var array - */ - protected $url = []; - - /** - * Sitemap domain name - * @var string - */ - protected $domain; - - /** - * Sitemap name - * @var string - */ - protected $name; - - /** - * Maximum urls in single sitemap (The maximum by google is 50000 urls but not 50MB in size) - * @var integer - */ - protected $max = 30000; - - /** - * @var SimpleXMLElement - */ - protected $doc; - - /** - * Sitemap options - */ - protected $options = - [ - 'images' => false, - 'videos' => false, - 'localized' => false, - ]; - - - /** - * Initialize sitemap builder - * - * @param string $domain - * @param array|null $options - * @param string $ns Additional namespaces. - */ - public function __construct(string $domain, ?array $options = null, string $ns = '') - { - $this->domain = trim($domain,'/'); - $this->options = array_merge($this->options, $options ?? []); - - $urlset = 'domain.'/sitemaps_xsl.xsl"?> options['images']) { - $urlset .= ' xmlns:image="'. static::IMAGE_NS .'"'; - } - - if ($this->options['videos']) { - $urlset .= ' xmlns:video="'. static::VIDEO_NS .'"'; - } - - if ($this->options['localized']) { - $urlset .= 'xmlns:xhtml="'. static::XHTML_NS .'"'; - } - - $this->doc = new SimpleXMLElement('' . $urlset . "{$ns}/>"); - } - - /** - * Append last url and start new one - * - * @param string $path - * @return SitemapBuilderInterface - */ - public function loc(string $path): SitemapBuilderInterface - { - if ($path[0] !== '/') { - $path = "/{$path}"; - } - - return $this->append()->url($this->domain . $path); - } - - public function url(string $url): SitemapBuilderInterface - { - if ($this->max <= 0) { - throw new SitemapException("The maximum urls has been exhausted"); - } - - $this->url['loc'] = Escape::escapeUrl($url); - return $this; - } - - /** - * Set alternative language url for multi lang support. - * - * @param string $url - * @param string $lang ISO 639-1 or ISO 3166-1 alpha-2 - * @return SitemapBuilderInterface - */ - public function alternate(string $path, string $lang) - { - if ($path[0] !== '/') { - $path = "/{$path}"; - } - - $this->url['alternate'][] = [Escape::escapeUrl($this->domain . $path), $lang]; - return $this; - } - - /** - * Append url - * - * @return SitemapBuilderInterface - */ - public function append(): SitemapBuilderInterface - { - if (empty($this->url) === false) { - - $url = $this->doc->addChild('url'); - - foreach ($this->url as $n => $v) - { - if ($n === 'image' || $n === 'video') { - - foreach ($v as $options) - { - $child = $url->addChild( - "{$n}:{$n}", null, ($n === 'image' ? static::IMAGE_NS : static::VIDEO_NS) - ); - - foreach ($options as $x => $o) - { - $child->addChild("{$n}:{$x}", $o); - } - } - - continue; - - } elseif ($n === 'news') { - - $child = $url->addChild('news:news', null, static::NEWS_NS); - - $pub = $child->addChild('news:publication'); - $pub->addChild('news:name', $v['name']); - $pub->addChild('news:language', $v['language']); - unset($v['name'], $v['language']); - - foreach ($v as $k => $p) - { - $child->addChild("{$n}:{$k}", $p); - } - - continue; - - } elseif ($n === 'alternate') { - - - foreach ($v as $k => $alt) - { - $child = $url->addChild('xhtml:link', null, static::XHTML_NS); - $child->addAttribute('rel', 'alternate'); - $child->addAttribute('href', $alt[0]); - $child->addAttribute('hreflang', $alt[1]); - } - - continue; - } - - $url->addChild($n, $v); - } - - $this->max--; - $this->url = []; - } - - return $this; - } - - /** - * Last modification date - * - * @return SitemapBuilderInterface - */ - public function lastMod($date): SitemapBuilderInterface - { - $this->url['lastmod'] = $this->pasreDate($date); - - return $this; - } - - /** - * Set image - * - * @todo Validate image options - * @param string $imageUrl - * @param array $options - * @return SitemapBuilderInterface - */ - public function image(string $imageUrl, array $options = []): SitemapBuilderInterface - { - if ($this->options['images'] === false) { - throw new SitemapException("Before set a image, enable images option"); - } - - $options['loc'] = $this->getByRelativeUrl($imageUrl); - $this->url['image'][] = $options; - - return $this; - } - - /** - * Set a video - * - * @param string $title - * @param array $options - * @return SitemapBuilderInterface - */ - public function video(string $title, array $options = []): SitemapBuilderInterface - { - if ($this->options['videos'] === false) { - throw new SitemapException("Before set a video, enable videos option first"); - } - - $options['title'] = $title; - - if (isset($options['thumbnail'])) { - - $options['thumbnail_loc'] = $options['thumbnail']; - unset($options['thumbnail']); - } - - foreach ($this->validation['video'] as $v) - { - if (isset($options[$v]) === false) { - throw new SitemapException("video {$v} options is required"); - } - } - - if (isset($options['content_loc']) === false && isset($options['player_loc']) === false) { - throw new SitemapException("Raw video url content_loc or player_loc is required"); - } - - $this->url['video'][] = $options; - - return $this; - } - - /** - * @param string $freq - * @return SitemapBuilderInterface - */ - public function changeFreq(string $freq): SitemapBuilderInterface - { - if (in_array($freq, $this->validation['freq']) === false) { - throw new SitemapException("changefreq value not valid"); - } - - $this->url['changefreq'] = $freq; - - return $this; - } - - /** - * changefreq alias - * - * @param string $freq - * @return SitemapBuilderInterface - */ - public function freq(string $freq): SitemapBuilderInterface - { - return $this->changefreq($freq); - } - - /** - * Url priority - * - * @param string $priority - * @return SitemapBuilderInterface - */ - public function priority(string $priority): SitemapBuilderInterface - { - $this->url['priority'] = $priority; - return $this; - } - - /** - * Get domain name - * - * @return string - */ - public function getDomain(): string - { - return $this->domain; - } - - /** - * Save generated sitemap as file - * - * @param string $path - * @return bool - */ - public function saveTo(string $path): bool - { - return $this->append()->getDoc()->asXML($path); - } - - /** - * Save to temp - * - * @return string - */ - public function saveTemp(): string - { - if ($this->saveTo($temp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . md5(uniqid()))) { - return $temp; - } - - throw new SitemapException("Saving {$this->name} to temp failed"); - } - - - public function getDoc(): SimpleXMLElement - { - return $this->doc; - } - - /** - * Fix relative urls - * - * @param string $url - * @return string - */ - protected function getByRelativeUrl(string $url): string - { - if (strpos($url, '://') === false) { - $url = $this->domain . ($url[0] !== '/' ? "{$url}/" : $url); - } - - return $url; - } - - /** - * Convert date to ISO8601 format - * - * @param int|string $date - * @return string - */ - protected function pasreDate($date): string - { - if (is_int($date) === false) { - $date = strtotime($date); - } - - return date('c', $date); - } - -} diff --git a/plugins/SiteSEO/Sitemap/SitemapIndex.php b/plugins/SiteSEO/Sitemap/SitemapIndex.php deleted file mode 100644 index eb06e66..0000000 --- a/plugins/SiteSEO/Sitemap/SitemapIndex.php +++ /dev/null @@ -1,53 +0,0 @@ - '); - - foreach ($maps as $name => $file) - { - if (rename($file, ($dest = $path . $name)) === false) { - throw new SitemapException("Moving the file {$dest} failed!"); - } - - $sitemap = $dom->addChild('sitemap'); - $sitemap->addChild('loc', $url . $name); - $sitemap->addChild('lastmod', date('c')); - } - - return $dom->asXML($path . $index); - } -} diff --git a/plugins/SiteSEO/Sitemap/SitemapParser.php b/plugins/SiteSEO/Sitemap/SitemapParser.php deleted file mode 100644 index f454432..0000000 --- a/plugins/SiteSEO/Sitemap/SitemapParser.php +++ /dev/null @@ -1,79 +0,0 @@ -xml = simplexml_load_string($xmlString); - } - - public function getUrls() - { - $urls = []; - foreach ($this->xml->url as $url) { - $urlData = [ - 'loc' => (string)$url->loc, - 'changefreq' => (string)$url->changefreq, - 'priority' => (string)$url->priority, - ]; - - // Check if image data is available - if ($url->image) { - $imageData = [ - 'image:caption' => (string)$url->image->children('image', true)->caption, - 'image:loc' => (string)$url->image->children('image', true)->loc, - ]; - - $urlData['image'] = $imageData; - } - - $urls[] = $urlData; - } - - return $urls; - } -} - -// // Sample XML string -// $xmlString = ' -// -// -// -// https://example.com/blog -// daily -// 0.8 -// -// -// https://example.com/blog/my-new-article -// weekly -// 2019-03-01T00:00:00+01:00 -// -// My caption -// https://example.com/uploads/image.jpeg -// -// -// '; - -// // Create an instance of the SitemapParser -// $parser = new SitemapParser($xmlString); - -// // Get the URLs and their data -// $urls = $parser->getUrls(); - -// // Print the results -// foreach ($urls as $urlData) { -// echo "URL: " . $urlData['loc'] . "\n"; -// echo "Change Frequency: " . $urlData['changefreq'] . "\n"; -// echo "Priority: " . $urlData['priority'] . "\n"; - -// if (isset($urlData['image'])) { -// echo "Image Caption: " . $urlData['image']['image:caption'] . "\n"; -// echo "Image Location: " . $urlData['image']['image:loc'] . "\n"; -// } - -// echo "\n"; -// } diff --git a/plugins/SiteSEO/StructuredProperties/Audio.php b/plugins/SiteSEO/StructuredProperties/Audio.php deleted file mode 100644 index 213a5bc..0000000 --- a/plugins/SiteSEO/StructuredProperties/Audio.php +++ /dev/null @@ -1,22 +0,0 @@ -setProperty(self::PREFIX, 'secure_url', $url); - - return $this; - } - - public function mimeType(string $mimeType) - { - $this->setProperty(self::PREFIX, 'type', $mimeType); - - return $this; - } -} diff --git a/plugins/SiteSEO/StructuredProperties/Image.php b/plugins/SiteSEO/StructuredProperties/Image.php deleted file mode 100644 index 65a3b67..0000000 --- a/plugins/SiteSEO/StructuredProperties/Image.php +++ /dev/null @@ -1,43 +0,0 @@ -setProperty(self::PREFIX, 'secure_url', $url); - - return $this; - } - - public function mimeType(string $mimeType) - { - $this->setProperty(self::PREFIX, 'type', $mimeType); - - return $this; - } - - public function width(int $width) - { - $this->setProperty(self::PREFIX, 'width', $width); - - return $this; - } - - public function height(int $height) - { - $this->setProperty(self::PREFIX, 'height', $height); - - return $this; - } - - public function alt(string $alt) - { - $this->setProperty(self::PREFIX, 'alt', $alt); - - return $this; - } -} diff --git a/plugins/SiteSEO/StructuredProperties/StructuredProperty.php b/plugins/SiteSEO/StructuredProperties/StructuredProperty.php deleted file mode 100644 index 5e20c50..0000000 --- a/plugins/SiteSEO/StructuredProperties/StructuredProperty.php +++ /dev/null @@ -1,23 +0,0 @@ -setProperty(static::PREFIX, 'url', $url); - } else { - $prefix = explode(':', static::PREFIX, 2); - $this->setProperty($prefix[0], $prefix[1], $url); - } - } - - public static function make(string $url, bool $withUrlSuffix = true) - { - return new static($url, $withUrlSuffix); - } -} diff --git a/plugins/SiteSEO/StructuredProperties/Video.php b/plugins/SiteSEO/StructuredProperties/Video.php deleted file mode 100644 index d99979f..0000000 --- a/plugins/SiteSEO/StructuredProperties/Video.php +++ /dev/null @@ -1,43 +0,0 @@ -setProperty(self::PREFIX, 'secure_url', $url); - - return $this; - } - - public function mimeType(string $mimeType) - { - $this->setProperty(self::PREFIX, 'type', $mimeType); - - return $this; - } - - public function width(int $width) - { - $this->setProperty(self::PREFIX, 'width', $width); - - return $this; - } - - public function height(int $height) - { - $this->setProperty(self::PREFIX, 'height', $height); - - return $this; - } - - public function alt(string $alt) - { - $this->setProperty(self::PREFIX, 'alt', $alt); - - return $this; - } -} diff --git a/plugins/SiteSEO/Support/ConditionalCallProxy.php b/plugins/SiteSEO/Support/ConditionalCallProxy.php deleted file mode 100644 index 8c03f65..0000000 --- a/plugins/SiteSEO/Support/ConditionalCallProxy.php +++ /dev/null @@ -1,27 +0,0 @@ - $arguments - */ - public function __call(string $name, array $arguments): object - { - return $this->target; - } -} diff --git a/plugins/SiteSEO/Support/HasConditionalCalls.php b/plugins/SiteSEO/Support/HasConditionalCalls.php deleted file mode 100644 index f35c380..0000000 --- a/plugins/SiteSEO/Support/HasConditionalCalls.php +++ /dev/null @@ -1,36 +0,0 @@ -when($cond)->method(...)` executes `method` only when `$cond` is - * truthy; otherwise a no-op proxy is returned so the chain continues without - * mutating the object. `unless()` is the inverse. - */ -trait HasConditionalCalls -{ - /** - * Return the object itself when the condition holds, otherwise a no-op - * proxy that swallows the next method call and returns the object. - * - * @return $this|ConditionalCallProxy - */ - public function when(mixed $condition): mixed - { - return $condition ? $this : new ConditionalCallProxy($this); - } - - /** - * Inverse of {@see when()}. - * - * @return $this|ConditionalCallProxy - */ - public function unless(mixed $condition): mixed - { - return $condition ? new ConditionalCallProxy($this) : $this; - } -} diff --git a/plugins/SiteSEO/Twitter.php b/plugins/SiteSEO/Twitter.php deleted file mode 100644 index 5280b2a..0000000 --- a/plugins/SiteSEO/Twitter.php +++ /dev/null @@ -1,25 +0,0 @@ -content); - - return "prefix}:{$this->property}\" content=\"{$content}\">"; - } -} diff --git a/plugins/SiteSEO/TwitterType.php b/plugins/SiteSEO/TwitterType.php deleted file mode 100644 index 7b23226..0000000 --- a/plugins/SiteSEO/TwitterType.php +++ /dev/null @@ -1,83 +0,0 @@ -setProperty(self::PREFIX, 'card', $this->type); - $this->when($title)->title($title); - } - - public static function make(string|null $title = null) - { - return new static($title); - } - - public function title(string|null $title) - { - $this->setProperty(self::PREFIX, 'title', $title); - - return $this; - } - - public function site(string $site) - { - $this->setProperty(self::PREFIX, 'site', $site); - - return $this; - } - - public function url(string $url) - { - $this->setProperty(self::PREFIX, 'url', $url); - - return $this; - } - - public function description(string $description) - { - $this->setProperty(self::PREFIX, 'description', $description); - - return $this; - } - - public function image(string $image, null|string $alt = null) - { - $this->setProperty(self::PREFIX, 'image', $image); - $this->when($alt)->setProperty(self::PREFIX, 'image:alt', $alt); - - return $this; - } - - public function setProperty(string $prefix, string $property, string $content) - { - $this->tags[$prefix.':'.$property] = TwitterProperty::make($prefix, $property, $content); - } - - public function addProperty(string $prefix, string $property, string $content) - { - $this->tags[] = TwitterProperty::make($prefix, $property, $content); - } - public function addStructuredProperty(BaseObject $property) - { - // Twitter cards only understand a single image URL plus optional alt - // text — they have no width/height/secure_url/type tags. Map the OG - // structured image onto the correct twitter:image / twitter:image:alt - // tags instead of leaking og:image:* keys as bogus twitter:* tags (which - // would also clobber twitter:url with the image URL). - $props = $property->getProperties(); - - $imageUrl = $props['secure_url'] ?? $props['url'] ?? null; - if ($imageUrl !== null) { - $this->setProperty(self::PREFIX, 'image', (string) $imageUrl); - } - - if (isset($props['alt'])) { - $this->setProperty(self::PREFIX, 'image:alt', (string) $props['alt']); - } - } -} diff --git a/plugins/SiteSEO/Type.php b/plugins/SiteSEO/Type.php deleted file mode 100644 index b021357..0000000 --- a/plugins/SiteSEO/Type.php +++ /dev/null @@ -1,184 +0,0 @@ - 'Hkmcode Website', - 'description' => '' - ]; - - public function __construct(string|null $title = null) - { - $this->setProperty('og', 'type', $this->type); - $this->when($title)->title($title); - $this->prs['title'] = $title; - $twitterMeta = new Twitter(); - $this->twitterMeta = $twitterMeta->summary($title); - } - - public static function make(string|null $title = null) - { - return new static($title); - } - - public function title(string $title) - { - $this->setProperty('og', 'title', $title); - - return $this; - } - - public function url(string $url) - { - $this->setProperty('og', 'url', $url); - $this->twitterMeta->url($url); - - return $this; - } - - public function description(string $description) - { - $this->setProperty('og', 'description', $description); - $this->twitterMeta->description($description); - $this->prs['description'] = $description; - - return $this; - } - - public function determiner(string $determiner) - { - $this->setProperty('og', 'determiner', $determiner); - - return $this; - } - - public function locale(string $locale) - { - $this->setProperty('og', 'locale', $locale); - - return $this; - } - - public function siteName(string $locale) - { - $this->setProperty('og', 'site_name', $locale); - - return $this; - } - - public function alternateLocale(string $locale) - { - $this->addProperty('og', 'locale:alternate', $locale); - - return $this; - } - - /** - * Switch the Twitter card to "summary_large_image" (big hero image) instead - * of the default "summary" (small thumbnail). The right default for articles, - * products and any page with a wide cover image. - * - * @return $this - */ - public function twitterLargeImage() - { - $this->twitterMeta->setProperty('twitter', 'card', 'summary_large_image'); - - return $this; - } - - /** - * @param Image|string $image - * @return $this - */ - public function image($image) - { - if ($image instanceof Image) { - $this->addStructuredProperty($image); - $this->twitterMeta->addStructuredProperty($image); - - return $this; - } - - $this->addProperty('og', 'image', $image); - $this->twitterMeta->image($image); - - return $this; - } - - /** - * @param Video|string $video - * @return $this - */ - public function video($video) - { - if ($video instanceof Video) { - $this->addStructuredProperty($video); - - return $this; - } - - $this->addProperty('og', 'video', $video); - - return $this; - } - - /** - * @param Audio|string $audio - * @return $this - */ - public function audio($audio) - { - if ($audio instanceof Audio) { - $this->addStructuredProperty($audio); - - return $this; - } - - $this->addProperty('og', 'audio', $audio); - - return $this; - } - - public function addStructuredProperty(BaseObject $property) - { - $this->tags[] = $property; - } - - public function __toString(): string - { - - $contents = parent::__toString(); - - $conTwitter = (string) $this->twitterMeta; - $conts = << - {$this->prs['title']} - - - - - $contents - - - $conTwitter - END; - return $conts; - } - -} diff --git a/plugins/SiteSEO/Types/Article.php b/plugins/SiteSEO/Types/Article.php deleted file mode 100644 index 4f6bb03..0000000 --- a/plugins/SiteSEO/Types/Article.php +++ /dev/null @@ -1,56 +0,0 @@ -addProperty(self::PREFIX, 'author', $url); - - return $this; - } - - public function section(string $section) - { - $this->setProperty(self::PREFIX, 'section', $section); - - return $this; - } - - public function tag(string $tag) - { - $this->addProperty(self::PREFIX, 'tag', $tag); - - return $this; - } - - public function publishedAt(DateTime $releasedAt) - { - $this->setProperty(self::PREFIX, 'published_time', $releasedAt->format(DateTime::ISO8601)); - - return $this; - } - - public function modifiedAt(DateTime $modifiedAt) - { - $this->setProperty(self::PREFIX, 'modified_time', $modifiedAt->format(DateTime::ISO8601)); - - return $this; - } - - public function expiresAt(DateTime $eexpiresAt) - { - $this->setProperty(self::PREFIX, 'expiration_time', $eexpiresAt->format(DateTime::ISO8601)); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Book.php b/plugins/SiteSEO/Types/Book.php deleted file mode 100644 index f82f10a..0000000 --- a/plugins/SiteSEO/Types/Book.php +++ /dev/null @@ -1,42 +0,0 @@ -addProperty(self::PREFIX, 'author', $url); - - return $this; - } - - public function isbn(string $isbn) - { - $this->setProperty(self::PREFIX, 'isbn', $isbn); - - return $this; - } - - public function releasedAt(DateTime $releaseDate) - { - $this->setProperty(self::PREFIX, 'release_date', $releaseDate->format('Y-m-d')); - - return $this; - } - - public function tag(string $tag) - { - $this->addProperty(self::PREFIX, 'tag', $tag); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Music/Album.php b/plugins/SiteSEO/Types/Music/Album.php deleted file mode 100644 index 58ab6c2..0000000 --- a/plugins/SiteSEO/Types/Music/Album.php +++ /dev/null @@ -1,37 +0,0 @@ -addProperty(self::PREFIX, 'musician', $url); - - return $this; - } - - public function song(string $url, int $disc = null, int $track = null) - { - $this->addProperty(self::PREFIX, 'song', $url); - $this->when($disc > 0)->addProperty(self::PREFIX, 'song:disc', $disc); - $this->when($track > 0)->addProperty(self::PREFIX, 'song:track', $track); - - return $this; - } - - public function releasedAt(DateTime $releaseDate) - { - $this->setProperty(self::PREFIX, 'release_date', $releaseDate->format('Y-m-d')); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Music/Playlist.php b/plugins/SiteSEO/Types/Music/Playlist.php deleted file mode 100644 index aa57b11..0000000 --- a/plugins/SiteSEO/Types/Music/Playlist.php +++ /dev/null @@ -1,29 +0,0 @@ -setProperty(self::PREFIX, 'creator', $url); - - return $this; - } - - public function song(string $url, int $disc = null, int $track = null) - { - $this->addProperty(self::PREFIX, 'song', $url); - $this->when($disc > 0)->addProperty(self::PREFIX, 'song:disc', $disc); - $this->when($track > 0)->addProperty(self::PREFIX, 'song:track', $track); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Music/RadioStation.php b/plugins/SiteSEO/Types/Music/RadioStation.php deleted file mode 100644 index 655296b..0000000 --- a/plugins/SiteSEO/Types/Music/RadioStation.php +++ /dev/null @@ -1,20 +0,0 @@ -setProperty(self::PREFIX, 'creator', $url); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Music/Song.php b/plugins/SiteSEO/Types/Music/Song.php deleted file mode 100644 index 2aaf3de..0000000 --- a/plugins/SiteSEO/Types/Music/Song.php +++ /dev/null @@ -1,36 +0,0 @@ -setProperty(self::PREFIX, 'duration', $seconds); - - return $this; - } - - public function musician(string $url) - { - $this->addProperty(self::PREFIX, 'musician', $url); - - return $this; - } - - public function album(string $url, int $disc = null, int $track = null) - { - $this->addProperty(self::PREFIX, 'album', $url); - $this->when($disc > 0)->addProperty(self::PREFIX, 'album:disc', $disc); - $this->when($track > 0)->addProperty(self::PREFIX, 'album:track', $track); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Profile.php b/plugins/SiteSEO/Types/Profile.php deleted file mode 100644 index e9d9322..0000000 --- a/plugins/SiteSEO/Types/Profile.php +++ /dev/null @@ -1,41 +0,0 @@ -setProperty(self::PREFIX, 'first_name', $firstName); - - return $this; - } - - public function lastName(string $lastName) - { - $this->setProperty(self::PREFIX, 'last_name', $lastName); - - return $this; - } - - public function username(string $username) - { - $this->setProperty(self::PREFIX, 'username', $username); - - return $this; - } - - public function gender(string $gender) - { - $this->setProperty(self::PREFIX, 'gender', $gender); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Twitter/App.php b/plugins/SiteSEO/Types/Twitter/App.php deleted file mode 100644 index 3860912..0000000 --- a/plugins/SiteSEO/Types/Twitter/App.php +++ /dev/null @@ -1,54 +0,0 @@ -setProperty(self::PREFIX, 'app:name:iphone', $name); - $this->setProperty(self::PREFIX, 'app:id:iphone', $iPhoneAppId); - if (! empty($iPhoneAppUrl)) { - $this->setProperty(self::PREFIX, 'app:url:iphone', $iPhoneAppUrl); - } - - return $this; - } - - public function iPadApp(string $name, string $iPadAppId, string $iPadAppUrl = null) - { - $this->setProperty(self::PREFIX, 'app:name:ipad', $name); - $this->setProperty(self::PREFIX, 'app:id:ipad', $iPadAppId); - if (! empty($iPadAppUrl)) { - $this->setProperty(self::PREFIX, 'app:url:ipad', $iPadAppUrl); - } - - return $this; - } - - public function googlePlayApp(string $name, string $googlePlayAppId, string $googlePlayAppUrl = null) - { - $this->setProperty(self::PREFIX, 'app:name:googleplay', $name); - $this->setProperty(self::PREFIX, 'app:id:googleplay', $googlePlayAppId); - if (! empty($googlePlayAppUrl)) { - $this->setProperty(self::PREFIX, 'app:url:googleplay', $googlePlayAppUrl); - } - - return $this; - } - - public function country(string $county = null) - { - if (! empty($county)) { - $county = strtoupper($county); - $this->setProperty(self::PREFIX, 'app:country', $county); - } - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Twitter/Player.php b/plugins/SiteSEO/Types/Twitter/Player.php deleted file mode 100644 index 7c92617..0000000 --- a/plugins/SiteSEO/Types/Twitter/Player.php +++ /dev/null @@ -1,20 +0,0 @@ -setProperty(self::PREFIX, 'player', $url); - $this->setProperty(self::PREFIX, 'player:width', $width); - $this->setProperty(self::PREFIX, 'player:height', $height); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Twitter/Summary.php b/plugins/SiteSEO/Types/Twitter/Summary.php deleted file mode 100644 index 4a9f248..0000000 --- a/plugins/SiteSEO/Types/Twitter/Summary.php +++ /dev/null @@ -1,11 +0,0 @@ -setProperty(self::PREFIX, 'creator', $creator); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Video/Episode.php b/plugins/SiteSEO/Types/Video/Episode.php deleted file mode 100644 index cf61d34..0000000 --- a/plugins/SiteSEO/Types/Video/Episode.php +++ /dev/null @@ -1,64 +0,0 @@ -setProperty(self::PREFIX, 'series', $url); - - return $this; - } - - public function actor(string $url, string $role = null) - { - $this->addProperty(self::PREFIX, 'actor', $url); - $this->when($role)->addProperty(self::PREFIX, 'actor:role', $role); - - return $this; - } - - public function director(string $url) - { - $this->addProperty(self::PREFIX, 'director', $url); - - return $this; - } - - public function writer(string $url) - { - $this->addProperty(self::PREFIX, 'writer', $url); - - return $this; - } - - public function duration(int $seconds) - { - $this->setProperty(self::PREFIX, 'duration', $seconds); - - return $this; - } - - public function releasedAt(DateTime $releaseDate) - { - $this->setProperty(self::PREFIX, 'release_date', $releaseDate->format('Y-m-d')); - - return $this; - } - - public function tag(string $tag) - { - $this->addProperty(self::PREFIX, 'tag', $tag); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Video/Movie.php b/plugins/SiteSEO/Types/Video/Movie.php deleted file mode 100644 index d758384..0000000 --- a/plugins/SiteSEO/Types/Video/Movie.php +++ /dev/null @@ -1,57 +0,0 @@ -addProperty(self::PREFIX, 'actor', $url); - $this->when($role)->addProperty(self::PREFIX, 'actor:role', $role); - - return $this; - } - - public function director(string $url) - { - $this->addProperty(self::PREFIX, 'director', $url); - - return $this; - } - - public function writer(string $url) - { - $this->addProperty(self::PREFIX, 'writer', $url); - - return $this; - } - - public function duration(int $seconds) - { - $this->setProperty(self::PREFIX, 'duration', $seconds); - - return $this; - } - - public function releasedAt(DateTime $releaseDate) - { - $this->setProperty(self::PREFIX, 'release_date', $releaseDate->format('Y-m-d')); - - return $this; - } - - public function tag(string $tag) - { - $this->addProperty(self::PREFIX, 'tag', $tag); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Video/Other.php b/plugins/SiteSEO/Types/Video/Other.php deleted file mode 100644 index 0100c15..0000000 --- a/plugins/SiteSEO/Types/Video/Other.php +++ /dev/null @@ -1,57 +0,0 @@ -addProperty(self::PREFIX, 'actor', $url); - $this->when($role)->addProperty(self::PREFIX, 'actor:role', $role); - - return $this; - } - - public function director(string $url) - { - $this->addProperty(self::PREFIX, 'director', $url); - - return $this; - } - - public function writer(string $url) - { - $this->addProperty(self::PREFIX, 'writer', $url); - - return $this; - } - - public function duration(int $seconds) - { - $this->setProperty(self::PREFIX, 'duration', $seconds); - - return $this; - } - - public function releasedAt(DateTime $releaseDate) - { - $this->setProperty(self::PREFIX, 'release_date', $releaseDate->format('Y-m-d')); - - return $this; - } - - public function tag(string $tag) - { - $this->addProperty(self::PREFIX, 'tag', $tag); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Video/TvShow.php b/plugins/SiteSEO/Types/Video/TvShow.php deleted file mode 100644 index d59bd87..0000000 --- a/plugins/SiteSEO/Types/Video/TvShow.php +++ /dev/null @@ -1,50 +0,0 @@ -addProperty(self::PREFIX, 'actor', $url); - $this->when($role)->addProperty(self::PREFIX, 'actor:role', $role); - - return $this; - } - - public function director(string $url) - { - $this->addProperty(self::PREFIX, 'director', $url); - - return $this; - } - - public function writer(string $url) - { - $this->addProperty(self::PREFIX, 'writer', $url); - - return $this; - } - - public function releasedAt(DateTime $releaseDate) - { - $this->setProperty(self::PREFIX, 'release_date', $releaseDate->format('Y-m-d')); - - return $this; - } - - public function tag(string $tag) - { - $this->addProperty(self::PREFIX, 'tag', $tag); - - return $this; - } -} diff --git a/plugins/SiteSEO/Types/Website.php b/plugins/SiteSEO/Types/Website.php deleted file mode 100644 index 815c9e4..0000000 --- a/plugins/SiteSEO/Types/Website.php +++ /dev/null @@ -1,11 +0,0 @@ -addDirective(self::INDEX); - $this->addDirective(self::FOLLOW); - $this->addDirective(self::NOARCHIVE); - $this->addDirective(self::TRANSLATE); - $this->addDirective(self::MAX_SNIPPET, 150); // Setting max-snippet to 150 - $this->addDirective(self::MAX_IMAGE_PREVIEW, 'large'); // Setting max-image-preview to large - - return $this; - } - - public function is_private() - { - $this->addDirective(self::NOINDEX); - $this->addDirective(self::NOFOLLOW); - $this->addDirective(self::NOARCHIVE); - $this->addDirective(self::NOSNIPPET); - return $this; - } - - /** - * Add a directive to the list. - * - * @param string $directive The directive to add. - * @param mixed|null $value Optional value for the directive. - */ - public function addDirective(string $directive, $value = true): void - { - $this->directives[$directive] = $value; - } - - /** - * Generate X-Robots-Tag header. - * - * @return string The generated X-Robots-Tag header. - */ - public function generateHeader(): string - { - $directives = []; - foreach ($this->directives as $directive => $value) { - $directives[] = $value === true ? $directive : "$directive=$value"; - } - return 'X-Robots-Tag: ' . implode(', ', $directives); - } - - /** - * Generate meta tags for robots directives. - * - * @return string The generated meta tags. - */ - public function generateMetaTags(): string - { - $metaTags = ''; - $cont = ""; - foreach ($this->directives as $directive => $value) { - $content = $value === true ? $directive : "$directive:$value"; - $cont .=", ".$content; - } - $cont = trim($cont,", "); - $metaTags .= "" . PHP_EOL; - $metaTags .= "" . PHP_EOL; - $metaTags .= "" . PHP_EOL; - return $metaTags; - } - - public function __toString() - { - // Set X-Robots-Tag header - header($this->generateHeader()); - - // Generate meta tags - return $this->generateMetaTags(); - } -} diff --git a/plugins/SiteSEO/module.json b/plugins/SiteSEO/module.json deleted file mode 100644 index dd5e2fe..0000000 --- a/plugins/SiteSEO/module.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "siteseo", - "version": "1.0.0", - "solves": "seo.management", - "type": "module", - - "requires": ["http.client"], - "exposes": ["Plugins\\SiteSEO\\API\\Contracts\\SeoServiceContract"], - - "routes": [ - { "method": "GET", "path": "/robots.txt", "handler": "Plugins\\SiteSEO\\Infrastructure\\Http\\SeoController@robots" }, - { "method": "POST", "path": "/api/seo/ping", "handler": "Plugins\\SiteSEO\\Infrastructure\\Http\\SeoController@ping", "filters": ["auth"] }, - { "method": "POST", "path": "/api/seo/indexnow", "handler": "Plugins\\SiteSEO\\Infrastructure\\Http\\SeoController@indexNow", "filters": ["auth"] } - ], - - "jobs": [ - { "name": "seo.indexnow", "handler": "Plugins\\SiteSEO\\Application\\Jobs\\IndexNowJob", "queue": "indexing" } - ], - - "emits": [], - "listens": [], - - "config": [] -} diff --git a/plugins/SocialAuth/API/Contracts/SocialAuthServiceContract.php b/plugins/SocialAuth/API/Contracts/SocialAuthServiceContract.php deleted file mode 100644 index e1a738f..0000000 --- a/plugins/SocialAuth/API/Contracts/SocialAuthServiceContract.php +++ /dev/null @@ -1,29 +0,0 @@ - $config provider credentials keyed under "services" - */ - public function __construct( - private readonly array $config, - private readonly string $baseUrl, - ) { - } - - public function redirectUrl(string $driver): string - { - try { - return $this->manager()->driver($driver)->redirect()->getTargetUrl(); - } catch (\Throwable $e) { - throw new ServiceException( - 'social_auth.redirect.failed', - layer: 'service.social_auth', - context: ['driver' => $driver], - previous: $e, - ); - } - } - - public function userFromCallback(string $driver, KernelRequest $request): SocialUser - { - try { - $manager = $this->manager(static fn (): SocialiteRequest => SocialiteRequest::fromKernel($request)); - return $manager->driver($driver)->user(); - } catch (\Throwable $e) { - throw new ServiceException( - 'social_auth.callback.failed', - layer: 'service.social_auth', - context: ['driver' => $driver], - previous: $e, - ); - } - } - - private function manager(?callable $requestFactory = null): SocialiteManager - { - return new SocialiteManager($this->config, $requestFactory, $this->baseUrl); - } -} diff --git a/plugins/SocialAuth/Application/Services/SocialLoginService.php b/plugins/SocialAuth/Application/Services/SocialLoginService.php deleted file mode 100644 index cb413cc..0000000 --- a/plugins/SocialAuth/Application/Services/SocialLoginService.php +++ /dev/null @@ -1,144 +0,0 @@ -normalizedEmail($profile['email'] ?? null); - $name = $profile['name'] ?? null; - $avatar = $profile['avatar'] ?? null; - - // 1 — already linked. - $userId = $this->identities->findUserId($provider, $providerUserId); - if ($userId !== null) { - $user = $this->users->find($userId); - if ($user !== null) { - // Refresh the provider snapshot (best-effort). - $this->identities->link($provider, $providerUserId, $userId, $email, $name, $avatar); - - return $user; - } - } - - if ($email === null) { - throw new ServiceException( - 'social_auth.profile.missing_email', - layer: 'service.social_auth', - context: ['provider' => $provider], - ); - } - - // 2 — link to the existing account behind the provider-verified email. - $user = $this->users->findByIdentifier($email); - - // 3 — first sign-in: create the account. - $user ??= $this->createUser($email, $name, $profile['nickname'] ?? null); - - $this->identities->link($provider, $providerUserId, $user->id, $email, $name, $avatar); - - return $user; - } - - // ── Internals ─────────────────────────────────────────────────────────────── - - private function createUser(string $email, ?string $name, ?string $nickname): UserDTO - { - $profile = []; - if (\is_string($name) && trim($name) !== '') { - $parts = preg_split('/\s+/', trim($name), 2) ?: []; - $profile['first_name'] = mb_substr($parts[0] ?? '', 0, 80); - if (($parts[1] ?? '') !== '') { - $profile['last_name'] = mb_substr($parts[1], 0, 80); - } - } - - $dto = new RegisterUserDTO( - username: Username::fromString($this->usernameFor($nickname, $email)), - email: Email::fromString($email), - // Social accounts have no local password — mint an unguessable one. - // The user can set a real one later through the reset flow. - password: 'A1!' . bin2hex(random_bytes(24)), - profile: $profile, - ); - - $verificationToken = $this->users->registerPublic($dto); - - // The provider already verified this mailbox — activate immediately. - try { - $this->users->verifyEmailByToken($verificationToken); - } catch (\Throwable) { - // Non-fatal: the account just stays pending verification. - } - - $user = $this->users->findByIdentifier($email,true); - if ($user === null) { - throw new ServiceException('social_auth.register.lookup_failed', layer: 'service.social_auth'); - } - - return $user; - } - - /** nickname (sanitised) or email local-part, + 4 random hex chars. */ - private function usernameFor(?string $nickname, string $email): string - { - $base = \is_string($nickname) ? (string) preg_replace('/[^A-Za-z0-9._-]/', '', $nickname) : ''; - if (\strlen($base) < 2) { - $base = (string) preg_replace('/[^A-Za-z0-9._-]/', '', explode('@', $email)[0] ?? ''); - } - if (\strlen($base) < 2) { - $base = 'user'; - } - - return strtolower(substr($base, 0, 42)) . '_' . substr(bin2hex(random_bytes(2)), 0, 4); - } - - private function normalizedEmail(mixed $email): ?string - { - if (!\is_string($email)) { - return null; - } - - $email = mb_strtolower(trim($email)); - - return $email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) !== false ? $email : null; - } -} diff --git a/plugins/SocialAuth/Infrastructure/Gateways/ProviderTokenGateway.php b/plugins/SocialAuth/Infrastructure/Gateways/ProviderTokenGateway.php deleted file mode 100644 index 81367fa..0000000 --- a/plugins/SocialAuth/Infrastructure/Gateways/ProviderTokenGateway.php +++ /dev/null @@ -1,198 +0,0 @@ - $credentials driver-specific token fields - * @return array{id:string,email:?string,name:?string,nickname:?string,avatar:?string} - */ - public function verify(string $driver, array $credentials): array - { - return match ($driver) { - 'google' => $this->verifyGoogle($credentials), - 'apple' => $this->verifyApple($credentials), - default => throw new GatewayException( - "Token sign-in is not supported for provider [{$driver}].", - layer: 'gateway.social_auth', - ), - }; - } - - // ── Google ────────────────────────────────────────────────────────────────── - - /** @param array $credentials */ - private function verifyGoogle(array $credentials): array - { - $idToken = trim((string) ($credentials['id_token'] ?? '')); - $accessToken = trim((string) ($credentials['access_token'] ?? '')); - - if ($idToken !== '') { - return $this->googleFromTokeninfo($idToken); - } - if ($accessToken !== '') { - return $this->googleFromUserinfo($accessToken); - } - - throw new GatewayException('Google sign-in requires id_token or access_token.', layer: 'gateway.social_auth.google'); - } - - /** @return array{id:string,email:?string,name:?string,nickname:?string,avatar:?string} */ - private function googleFromTokeninfo(string $idToken): array - { - $claims = $this->getJson(self::GOOGLE_TOKENINFO, ['id_token' => $idToken], 'gateway.social_auth.google'); - - // tokeninfo already verified the signature; we must still pin the - // audience to OUR client id or any Google app's token would sign in. - if ($this->googleClientId !== '' && (string) ($claims['aud'] ?? '') !== $this->googleClientId) { - throw new GatewayException('Google id_token audience mismatch.', layer: 'gateway.social_auth.google'); - } - - if ((string) ($claims['sub'] ?? '') === '') { - throw new GatewayException('Google id_token verification failed.', layer: 'gateway.social_auth.google'); - } - - return [ - 'id' => (string) $claims['sub'], - 'email' => $this->verifiedEmail($claims['email'] ?? null, $claims['email_verified'] ?? null), - 'name' => isset($claims['name']) ? (string) $claims['name'] : null, - 'nickname' => null, - 'avatar' => isset($claims['picture']) ? (string) $claims['picture'] : null, - ]; - } - - /** @return array{id:string,email:?string,name:?string,nickname:?string,avatar:?string} */ - private function googleFromUserinfo(string $accessToken): array - { - $response = $this->http->request('GET', self::GOOGLE_USERINFO, [ - 'headers' => ['Authorization' => 'Bearer ' . $accessToken], - ]); - if ($response->failed()) { - throw new GatewayException('Google access_token verification failed.', layer: 'gateway.social_auth.google'); - } - - $info = $response->json(); - if (!\is_array($info) || (string) ($info['sub'] ?? '') === '') { - throw new GatewayException('Google userinfo response was malformed.', layer: 'gateway.social_auth.google'); - } - - return [ - 'id' => (string) $info['sub'], - 'email' => $this->verifiedEmail($info['email'] ?? null, $info['email_verified'] ?? null), - 'name' => isset($info['name']) ? (string) $info['name'] : null, - 'nickname' => null, - 'avatar' => isset($info['picture']) ? (string) $info['picture'] : null, - ]; - } - - // ── Apple ─────────────────────────────────────────────────────────────────── - - /** @param array $credentials */ - private function verifyApple(array $credentials): array - { - $identityToken = trim((string) ($credentials['identity_token'] ?? '')); - if ($identityToken === '') { - throw new GatewayException('Apple sign-in requires identity_token.', layer: 'gateway.social_auth.apple'); - } - - $jwks = $this->getJson(self::APPLE_JWKS, [], 'gateway.social_auth.apple'); - - try { - $claims = (array) JWT::decode($identityToken, JWK::parseKeySet($jwks)); - } catch (\Throwable $e) { - throw new GatewayException( - 'Apple identity_token signature verification failed.', - layer: 'gateway.social_auth.apple', - previous: $e, - ); - } - - if ((string) ($claims['iss'] ?? '') !== self::APPLE_ISSUER) { - throw new GatewayException('Apple identity_token issuer mismatch.', layer: 'gateway.social_auth.apple'); - } - if ($this->appleClientId !== '' && (string) ($claims['aud'] ?? '') !== $this->appleClientId) { - throw new GatewayException('Apple identity_token audience mismatch.', layer: 'gateway.social_auth.apple'); - } - if ((string) ($claims['sub'] ?? '') === '') { - throw new GatewayException('Apple identity_token verification failed.', layer: 'gateway.social_auth.apple'); - } - - // Apple sends the user's name only on FIRST authorization, as a separate - // client-side field — accept it as a hint (it is not security-relevant). - $name = trim((string) ($credentials['name'] ?? '')); - - return [ - 'id' => (string) $claims['sub'], - 'email' => $this->verifiedEmail($claims['email'] ?? null, $claims['email_verified'] ?? null), - 'name' => $name !== '' ? $name : null, - 'nickname' => null, - 'avatar' => null, - ]; - } - - // ── Shared ────────────────────────────────────────────────────────────────── - - /** @return array */ - private function getJson(string $url, array $query, string $layer): array - { - $response = $this->http->get($url, $query); - if ($response->failed()) { - throw new GatewayException('Provider verification endpoint failed.', layer: $layer); - } - - $json = $response->json(); - if (!\is_array($json)) { - throw new GatewayException('Provider verification response was malformed.', layer: $layer); - } - - return $json; - } - - /** Only trust an email the PROVIDER says is verified. */ - private function verifiedEmail(mixed $email, mixed $verified): ?string - { - if (!\is_string($email) || trim($email) === '') { - return null; - } - - // email_verified arrives as bool or the strings "true"/"false". - $isVerified = $verified === true || $verified === 'true' || $verified === 1 || $verified === '1'; - - return $isVerified ? mb_strtolower(trim($email)) : null; - } -} diff --git a/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php b/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php deleted file mode 100644 index e1b4e76..0000000 --- a/plugins/SocialAuth/Infrastructure/Http/Controllers/SocialAuthController.php +++ /dev/null @@ -1,162 +0,0 @@ -social->redirectUrl($driver)); - } catch (ServiceException) { - return $this->notFound("Unknown or unconfigured social provider [{$driver}]."); - } - } - - public function callback(string $driver): Response - { - $request = $this->resolveRequest(); - - try { - $profile = $this->social->userFromCallback($driver, $request); - $user = $this->login->resolveUser($driver, $this->profileArray($profile)); - } catch (ServiceException $e) { - return $this->socialFailure($e); - } - - if ($request->input('mode') === 'token' || $request->expectsJson()) { - return $this->ok(['user' => $user->toArray(), 'tokens' => $this->issueTokenPair($user)]); - } - - // Web flow: open a platform session and send the browser on its way — - // back to the page recorded by the Session plugin's StartSessionStage - // when there is one (validated: relative path only — open-redirect - // guard), else the configured default. - $this->auth->startSession($this->session, $user->id, username: $user->username, email: $user->email); - - $previous = $this->session->pull(StartSessionStage::PREVIOUS_URL); - $target = is_string($previous) - && $previous !== '' - && $previous[0] === '/' - && !str_starts_with($previous, '//') - && !str_starts_with($previous, '/\\') - ? $previous - : $this->successRedirect; - - return Response::redirect($target); - } - - public function token(string $driver): Response - { - $request = $this->resolveRequest(); - - try { - $profile = $this->tokens->verify($driver, [ - 'access_token' => (string) $request->input('access_token', ''), - 'id_token' => (string) $request->input('id_token', ''), - 'identity_token' => (string) $request->input('identity_token', ''), - 'name' => (string) $request->input('name', ''), - ]); - $user = $this->login->resolveUser($driver, $profile); - } catch (GatewayException $e) { - return Response::unauthorized($e->getMessage()); - } catch (ServiceException $e) { - return $this->socialFailure($e); - } - - return $this->ok(['user' => $user->toArray(), 'tokens' => $this->issueTokenPair($user)]); - } - - // ── Internals ─────────────────────────────────────────────────────────────── - - /** @return array the old api.md `tokens` shape */ - private function issueTokenPair(UserDTO $user): array - { - $request = $this->resolveRequest(); - $refresh = $this->refreshTokens->issue( - $user->id, - device: $request->header('User-Agent'), - ip: $request->ip(), - ); - - return [ - // Display claims passed explicitly — the user record is already in - // hand, so AuthService skips its central-lookup enrichment. - 'accessToken' => $this->auth->issueJwt($user->id, [ - 'preferred_username' => $user->username, - 'email' => $user->email, - ], $this->accessTtl), - 'tokenType' => 'Bearer', - 'expiresAt' => time() + $this->accessTtl, - 'refreshToken' => $refresh->token, - 'refreshExpiresAt' => $refresh->expiresAt, - ]; - } - - /** @return array{id:string,email:?string,name:?string,nickname:?string,avatar:?string} */ - private function profileArray(SocialUser $user): array - { - return [ - 'id' => (string) $user->getId(), - 'email' => $user->getEmail(), - 'name' => $user->getName(), - 'nickname' => $user->getNickname(), - 'avatar' => $user->getAvatar(), - ]; - } - - private function socialFailure(ServiceException $e): Response - { - $message = match ($e->getMessage()) { - 'social_auth.profile.missing_email' => - 'This provider account has no verified email — sign in with a provider that shares one, or register first.', - default => 'Social sign-in failed. Please try again.', - }; - - return Response::json(['error' => ['code' => $e->getMessage(), 'message' => $message]], 422); - } -} diff --git a/plugins/SocialAuth/Infrastructure/Persistence/SocialIdentityRepository.php b/plugins/SocialAuth/Infrastructure/Persistence/SocialIdentityRepository.php deleted file mode 100644 index 54f93d0..0000000 --- a/plugins/SocialAuth/Infrastructure/Persistence/SocialIdentityRepository.php +++ /dev/null @@ -1,102 +0,0 @@ -db->queryOne( - "SELECT user_id FROM {$this->table} WHERE provider = :provider AND provider_user_id = :pid", - ['provider' => $provider, 'pid' => $providerUserId] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to look up social identity', layer: 'repository.social_auth', previous: $e); - } - - return $row !== null ? (string) $row['user_id'] : null; - } - - /** Link (or refresh the snapshot of) a provider account for a user. */ - public function link( - string $provider, - string $providerUserId, - string $userId, - ?string $email, - ?string $name, - ?string $avatar, - ): void { - try { - $this->db->upsert( - $this->table, - [ - 'provider' => $provider, - 'provider_user_id' => $providerUserId, - 'user_id' => $userId, - 'email' => $email !== null ? mb_substr($email, 0, 150) : null, - 'name' => $name !== null ? mb_substr($name, 0, 120) : null, - 'avatar' => $avatar !== null ? mb_substr($avatar, 0, 255) : null, - 'updated_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ['provider', 'provider_user_id'], - // Refresh the snapshot columns; never move the link to another - // user implicitly (user_id excluded from the update set). - ['email', 'name', 'avatar', 'updated_at'], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to link social identity', layer: 'repository.social_auth', previous: $e); - } - } - - /** - * All linked providers for a user (for a "connected accounts" screen). - * - * @return list - */ - public function listForUser(string $userId): array - { - try { - $rows = $this->db->query( - "SELECT provider, provider_user_id, email, name, avatar, created_at - FROM {$this->table} WHERE user_id = :user_id ORDER BY provider", - ['user_id' => $userId] - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list social identities', layer: 'repository.social_auth', previous: $e); - } - - return array_values($rows); - } - - /** Unlink one provider account from a user. True when a row was removed. */ - public function unlink(string $userId, string $provider): bool - { - try { - return $this->db->execute( - "DELETE FROM {$this->table} WHERE user_id = :user_id AND provider = :provider", - ['user_id' => $userId, 'provider' => $provider] - ) > 0; - } catch (\PDOException $e) { - throw new RepositoryException('Failed to unlink social identity', layer: 'repository.social_auth', previous: $e); - } - } -} diff --git a/plugins/SocialAuth/Provider.php b/plugins/SocialAuth/Provider.php deleted file mode 100644 index 03ffdbe..0000000 --- a/plugins/SocialAuth/Provider.php +++ /dev/null @@ -1,133 +0,0 @@ - */ - public function requires(): array - { - // Mirrors module.json "requires": the login bridge maps provider - // profiles onto central users and issues platform credentials via the - // Auth plugin's published contracts; token sign-in verifies against the - // provider over HttpClientPort. - return ['database.management', 'user.management', 'auth.identity', 'http.client']; - } - - /** @return list */ - public function exposes(): array - { - return [SocialAuthServiceContract::class]; - } - - public function register(ModuleContainer $container): void - { - $container->bind(SocialAuthServiceContract::class, static function () { - return new SocialAuthService( - config: self::buildConfig(), - baseUrl: env('SOCIAL_AUTH_BASE_URL') ?: '', - ); - }); - - // Provider-account → user links (central — control-plane table). - $container->bindInternal(SocialIdentityRepository::class, static fn(ModuleContainer $c) => - new SocialIdentityRepository( - $c->make(DatabasePort::class), - ) - ); - - // Find-or-create bridge onto the central identity store. - $container->bindInternal(SocialLoginService::class, static fn(ModuleContainer $c) => - new SocialLoginService( - $c->make(SocialIdentityRepository::class), - $c->make(UserServiceContract::class), - ) - ); - - // Native-SDK token verification (google access_token/id_token, apple - // identity_token against Apple's JWKS). - $container->bindInternal(ProviderTokenGateway::class, static fn(ModuleContainer $c) => - new ProviderTokenGateway( - $c->make(HttpClientPort::class), - googleClientId: env('GOOGLE_CLIENT_ID') ?: '', - appleClientId: env('APPLE_CLIENT_ID') ?: '', - ) - ); - - $container->bindInternal(SocialAuthController::class, static fn(ModuleContainer $c) => - new SocialAuthController( - social: $c->make(SocialAuthServiceContract::class), - login: $c->make(SocialLoginService::class), - tokens: $c->make(ProviderTokenGateway::class), - auth: $c->make(AuthServiceContract::class), - refreshTokens: $c->make(RefreshTokenServiceContract::class), - session: $c->make(SessionPort::class), - accessTtl: (int) (env('AUTH_MOBILE_ACCESS_TTL') ?: 3600), - successRedirect: env('SOCIAL_AUTH_SUCCESS_REDIRECT') ?: '/', - ) - ); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // No hooks: drivers are invoked from a controller/service on demand. - } - - /** - * Assemble the Socialite-shaped config from env vars. - * - * @return array - */ - private static function buildConfig(): array - { - $services = []; - foreach (['github', 'google', 'facebook', 'gitlab', 'bitbucket', 'linkedin', 'slack', 'x'] as $driver) { - $prefix = strtoupper($driver); - $id = env("{$prefix}_CLIENT_ID"); - if ($id === false || $id === '') { - continue; - } - $services[$driver] = [ - 'client_id' => $id, - 'client_secret' => env("{$prefix}_CLIENT_SECRET") ?: '', - 'redirect' => env("{$prefix}_REDIRECT_URI") ?: '', - ]; - } - - return ['services' => $services]; - } -} diff --git a/plugins/SocialAuth/Socialite/AbstractUser.php b/plugins/SocialAuth/Socialite/AbstractUser.php deleted file mode 100644 index 117fe1d..0000000 --- a/plugins/SocialAuth/Socialite/AbstractUser.php +++ /dev/null @@ -1,210 +0,0 @@ -id; - } - - /** - * Get the nickname / username for the user. - * - * @return string|null - */ - public function getNickname() - { - return $this->nickname; - } - - /** - * Get the full name of the user. - * - * @return string|null - */ - public function getName() - { - return $this->name; - } - - /** - * Get the e-mail address of the user. - * - * @return string|null - */ - public function getEmail() - { - return $this->email; - } - - /** - * Get the avatar / image URL for the user. - * - * @return string|null - */ - public function getAvatar() - { - return $this->avatar; - } - - /** - * Get the raw user array. - * - * @return array - */ - public function getRaw() - { - return $this->user; - } - - /** - * Set the raw user array from the provider. - * - * @param array $user - * @return $this - */ - public function setRaw(array $user) - { - $this->user = $user; - - return $this; - } - - /** - * Map the given array onto the user's properties. - * - * @param array $attributes - * @return $this - */ - public function map(array $attributes) - { - $this->attributes = $attributes; - - foreach ($attributes as $key => $value) { - if (property_exists($this, $key)) { - $this->{$key} = $value; - } - } - - return $this; - } - - /** - * Determine if the given raw user attribute exists. - * - * @param string $offset - * @return bool - */ - #[\ReturnTypeWillChange] - public function offsetExists($offset):bool - { - return array_key_exists($offset, $this->user); - } - - /** - * Get the given key from the raw user. - * - * @param string $offset - * @return mixed - */ - #[\ReturnTypeWillChange] - public function offsetGet($offset):mixed - { - return $this->user[$offset]; - } - - /** - * Set the given attribute on the raw user array. - * - * @param string $offset - * @param mixed $value - * @return void - */ - #[\ReturnTypeWillChange] - public function offsetSet($offset, $value):void - { - $this->user[$offset] = $value; - } - - /** - * Unset the given value from the raw user array. - * - * @param string $offset - * @return void - */ - #[\ReturnTypeWillChange] - public function offsetUnset($offset):void - { - unset($this->user[$offset]); - } - - /** - * Get a user attribute value dynamically. - * - * @param string $key - * @return void - */ - public function __get($key) - { - return $this->attributes[$key] ?? null; - } -} diff --git a/plugins/SocialAuth/Socialite/Http/RedirectResponse.php b/plugins/SocialAuth/Socialite/Http/RedirectResponse.php deleted file mode 100644 index fc13710..0000000 --- a/plugins/SocialAuth/Socialite/Http/RedirectResponse.php +++ /dev/null @@ -1,33 +0,0 @@ -targetUrl; - } - - public function toResponse(): Response - { - return Response::redirect($this->targetUrl, $this->status); - } -} diff --git a/plugins/SocialAuth/Socialite/Http/Request.php b/plugins/SocialAuth/Socialite/Http/Request.php deleted file mode 100644 index 2cfddd7..0000000 --- a/plugins/SocialAuth/Socialite/Http/Request.php +++ /dev/null @@ -1,45 +0,0 @@ - $input */ - public function __construct( - private array $input = [], - private ?Session $session = null, - ) { - $this->session ??= new Session(); - } - - public static function fromKernel(KernelRequest $request): self - { - return new self(array_merge($request->queryAll(), $request->all())); - } - - public function input(string $key, mixed $default = null): mixed - { - return $this->input[$key] ?? $default; - } - - public function query(string $key, mixed $default = null): mixed - { - return $this->input[$key] ?? $default; - } - - public function session(): Session - { - return $this->session; - } -} diff --git a/plugins/SocialAuth/Socialite/Http/Session.php b/plugins/SocialAuth/Socialite/Http/Session.php deleted file mode 100644 index c9074ba..0000000 --- a/plugins/SocialAuth/Socialite/Http/Session.php +++ /dev/null @@ -1,45 +0,0 @@ -server = $server; - $this->request = $request; - } - - /** - * Redirect the user to the authentication page for the provider. - * - * @return RedirectResponse - */ - public function redirect() - { - $this->request->session()->put( - 'oauth.temp', $temp = $this->server->getTemporaryCredentials() - ); - - return new RedirectResponse($this->server->getAuthorizationUrl($temp)); - } - - /** - * Get the User instance for the authenticated user. - * - * @return \Plugins\SocialAuth\Socialite\One\User - * - * @throws \Plugins\SocialAuth\Socialite\One\MissingVerifierException - */ - public function user() - { - if (! $this->hasNecessaryVerifier()) { - throw new MissingVerifierException('Invalid request. Missing OAuth verifier.'); - } - - $token = $this->getToken(); - - $user = $this->server->getUserDetails( - $token, $this->shouldBypassCache($token->getIdentifier(), $token->getSecret()) - ); - - $instance = (new User)->setRaw($user->extra) - ->setToken($token->getIdentifier(), $token->getSecret()); - - return $instance->map([ - 'id' => $user->uid, - 'nickname' => $user->nickname, - 'name' => $user->name, - 'email' => $user->email, - 'avatar' => $user->imageUrl, - ]); - } - - /** - * Get a Social User instance from a known access token and secret. - * - * @param string $token - * @param string $secret - * @return \Plugins\SocialAuth\Socialite\One\User - */ - public function userFromTokenAndSecret($token, $secret) - { - $tokenCredentials = new TokenCredentials(); - - $tokenCredentials->setIdentifier($token); - $tokenCredentials->setSecret($secret); - - $user = $this->server->getUserDetails( - $tokenCredentials, $this->shouldBypassCache($token, $secret) - ); - - $instance = (new User)->setRaw($user->extra) - ->setToken($tokenCredentials->getIdentifier(), $tokenCredentials->getSecret()); - - return $instance->map([ - 'id' => $user->uid, - 'nickname' => $user->nickname, - 'name' => $user->name, - 'email' => $user->email, - 'avatar' => $user->imageUrl, - ]); - } - - /** - * Get the token credentials for the request. - * - * @return \League\OAuth1\Client\Credentials\TokenCredentials - */ - protected function getToken() - { - $temp = $this->request->session()->get('oauth.temp'); - - if (! $temp) { - throw new MissingTemporaryCredentialsException('Missing temporary OAuth credentials.'); - } - - return $this->server->getTokenCredentials( - $temp, $this->request->get('oauth_token'), $this->request->get('oauth_verifier') - ); - } - - /** - * Determine if the request has the necessary OAuth verifier. - * - * @return bool - */ - protected function hasNecessaryVerifier() - { - return $this->request->has(['oauth_token', 'oauth_verifier']); - } - - /** - * Determine if the user information cache should be bypassed. - * - * @param string $token - * @param string $secret - * @return bool - */ - protected function shouldBypassCache($token, $secret) - { - $newHash = sha1($token.'_'.$secret); - - if (! empty($this->userHash) && $newHash !== $this->userHash) { - $this->userHash = $newHash; - - return true; - } - - $this->userHash = $this->userHash ?: $newHash; - - return false; - } - - /** - * Set the request instance. - * - * @param Request $request - * @return $this - */ - public function setRequest(Request $request) - { - $this->request = $request; - - return $this; - } -} diff --git a/plugins/SocialAuth/Socialite/One/MissingTemporaryCredentialsException.php b/plugins/SocialAuth/Socialite/One/MissingTemporaryCredentialsException.php deleted file mode 100644 index 97c038d..0000000 --- a/plugins/SocialAuth/Socialite/One/MissingTemporaryCredentialsException.php +++ /dev/null @@ -1,10 +0,0 @@ -hasNecessaryVerifier()) { - throw new MissingVerifierException('Invalid request. Missing OAuth verifier.'); - } - - $user = $this->server->getUserDetails($token = $this->getToken(), $this->shouldBypassCache($token->getIdentifier(), $token->getSecret())); - - $extraDetails = [ - 'location' => $user->location, - 'description' => $user->description, - ]; - - $instance = (new User)->setRaw(array_merge($user->extra, $user->urls, $extraDetails)) - ->setToken($token->getIdentifier(), $token->getSecret()); - - return $instance->map([ - 'id' => $user->uid, - 'nickname' => $user->nickname, - 'name' => $user->name, - 'email' => $user->email, - 'avatar' => $user->imageUrl, - 'avatar_original' => str_replace('_normal', '', $user->imageUrl), - ]); - } - - /** - * Set the access level the application should request to the user account. - * - * @param string $scope - * @return void - */ - public function scope(string $scope) - { - $this->server->setApplicationScope($scope); - } -} diff --git a/plugins/SocialAuth/Socialite/One/User.php b/plugins/SocialAuth/Socialite/One/User.php deleted file mode 100644 index c52ce28..0000000 --- a/plugins/SocialAuth/Socialite/One/User.php +++ /dev/null @@ -1,37 +0,0 @@ -token = $token; - $this->tokenSecret = $tokenSecret; - - return $this; - } -} diff --git a/plugins/SocialAuth/Socialite/Ports/Factory.php b/plugins/SocialAuth/Socialite/Ports/Factory.php deleted file mode 100644 index 96ed68c..0000000 --- a/plugins/SocialAuth/Socialite/Ports/Factory.php +++ /dev/null @@ -1,14 +0,0 @@ - $config provider credentials keyed under "services" - * @param (callable(): Request)|null $requestFactory builds the per-request wrapper - */ - public function __construct(array $config = [], ?callable $requestFactory = null, string $baseUrl = '') - { - $this->config = new Config($config); - $this->requestFactory = $requestFactory !== null - ? \Closure::fromCallable($requestFactory) - : static fn (): Request => new Request(); - $this->baseUrl = rtrim($baseUrl, '/'); - } - - /** - * Get a driver instance. - * - * @param string $driver - * @return mixed - */ - public function with($driver) - { - return $this->driver($driver); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createGithubDriver() - { - $config = $this->config->get('services.github'); - - return $this->buildProvider( - GithubProvider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createFacebookDriver() - { - $config = $this->config->get('services.facebook'); - - return $this->buildProvider( - FacebookProvider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createGoogleDriver() - { - $config = $this->config->get('services.google'); - - return $this->buildProvider( - GoogleProvider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createLinkedinDriver() - { - $config = $this->config->get('services.linkedin'); - - return $this->buildProvider( - LinkedInProvider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createLinkedinOpenidDriver() - { - $config = $this->config->get('services.linkedin-openid'); - - return $this->buildProvider( - LinkedInOpenIdProvider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createBitbucketDriver() - { - $config = $this->config->get('services.bitbucket'); - - return $this->buildProvider( - BitbucketProvider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createGitlabDriver() - { - $config = $this->config->get('services.gitlab'); - - return $this->buildProvider( - GitlabProvider::class, $config - )->setHost($config['host'] ?? null); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\One\AbstractProvider|\Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createTwitterDriver() - { - $config = $this->config->get('services.twitter'); - - if (($config['oauth'] ?? null) === 2) { - return $this->createTwitterOAuth2Driver(); - } - - return new TwitterProvider( - ($this->requestFactory)(), new TwitterServer($this->formatConfig($config)) - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createTwitterOAuth2Driver() - { - $config = $this->config->get('services.twitter') ?? $this->config->get('services.twitter-oauth-2'); - - return $this->buildProvider( - TwitterOAuth2Provider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createXDriver() - { - $config = $this->config->get('services.x') ?? $this->config->get('services.x-oauth-2'); - - return $this->buildProvider( - XProvider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createSlackDriver() - { - $config = $this->config->get('services.slack'); - - return $this->buildProvider( - SlackProvider::class, $config - ); - } - - /** - * Create an instance of the specified driver. - * - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - protected function createSlackOpenidDriver() - { - $config = $this->config->get('services.slack-openid'); - - return $this->buildProvider( - SlackOpenIdProvider::class, $config - ); - } - - /** - * Build an OAuth 2 provider instance. - * - * @param string $provider - * @param array $config - * @return \Plugins\SocialAuth\Socialite\Two\AbstractProvider - */ - public function buildProvider($provider, $config) - { - return new $provider( - ($this->requestFactory)(), $config['client_id'], - $config['client_secret'], $this->formatRedirectUrl($config), - array_get($config, 'guzzle', []) - ); - } - - /** - * Format the server configuration. - * - * @param array $config - * @return array - */ - public function formatConfig(array $config) - { - return array_merge([ - 'identifier' => $config['client_id'], - 'secret' => $config['client_secret'], - 'callback_uri' => $this->formatRedirectUrl($config), - ], $config); - } - - /** - * Format the callback URL, resolving a relative URI if needed. - * - * @param array $config - * @return string - */ - protected function formatRedirectUrl(array $config) - { - $redirect = value($config['redirect']); - - return _str_starts_with($redirect ?? '', '/') - ? $this->baseUrl . $redirect - : $redirect; - } - - /** - * Forget all of the resolved driver instances. - * - * @return $this - */ - public function forgetDrivers() - { - $this->drivers = []; - - return $this; - } - - /** - * Get the default driver name. - * - * @return string - * - * @throws \InvalidArgumentException - */ - public function getDefaultDriver() - { - throw new InvalidArgumentException('No Socialite driver was specified.'); - } -} diff --git a/plugins/SocialAuth/Socialite/Support/Config.php b/plugins/SocialAuth/Socialite/Support/Config.php deleted file mode 100644 index bf67e93..0000000 --- a/plugins/SocialAuth/Socialite/Support/Config.php +++ /dev/null @@ -1,32 +0,0 @@ - ['github' => ['client_id' => ..., 'client_secret' => ..., 'redirect' => ...], ...]] - */ -final class Config -{ - /** @param array $items */ - public function __construct(private readonly array $items = []) - { - } - - public function get(string $key, mixed $default = null): mixed - { - $value = $this->items; - foreach (explode('.', $key) as $segment) { - if (is_array($value) && array_key_exists($segment, $value)) { - $value = $value[$segment]; - } else { - return $default; - } - } - return $value; - } -} diff --git a/plugins/SocialAuth/Socialite/Support/Manager.php b/plugins/SocialAuth/Socialite/Support/Manager.php deleted file mode 100644 index a3b463b..0000000 --- a/plugins/SocialAuth/Socialite/Support/Manager.php +++ /dev/null @@ -1,55 +0,0 @@ - */ - protected array $drivers = []; - - abstract public function getDefaultDriver(); - - public function driver($driver = null) - { - $driver ??= $this->getDefaultDriver(); - - if ($driver === null) { - throw new InvalidArgumentException( - sprintf('Unable to resolve NULL driver for [%s].', static::class) - ); - } - - return $this->drivers[$driver] ??= $this->createDriver($driver); - } - - protected function createDriver(string $driver): mixed - { - $method = 'create' . str_replace(['-', '_'], '', ucwords($driver, '-_')) . 'Driver'; - - if (method_exists($this, $method)) { - return $this->{$method}(); - } - - throw new InvalidArgumentException("Driver [{$driver}] not supported."); - } - - public function setContainer(ContainerInterface $container): static - { - $this->container = $container; - return $this; - } -} diff --git a/plugins/SocialAuth/Socialite/Support/helpers.php b/plugins/SocialAuth/Socialite/Support/helpers.php deleted file mode 100644 index 25ae258..0000000 --- a/plugins/SocialAuth/Socialite/Support/helpers.php +++ /dev/null @@ -1,42 +0,0 @@ -guzzle = $guzzle; - $this->request = $request; - $this->clientId = $clientId; - $this->redirectUrl = $redirectUrl; - $this->clientSecret = $clientSecret; - } - - /** - * Get the authentication URL for the provider. - * - * @param string $state - * @return string - */ - abstract protected function getAuthUrl($state); - - /** - * Get the token URL for the provider. - * - * @return string - */ - abstract protected function getTokenUrl(); - - /** - * Get the raw user for the given access token. - * - * @param string $token - * @return array - */ - abstract protected function getUserByToken($token); - - /** - * Map the raw user array to a Socialite User instance. - * - * @param array $user - * @return \Plugins\SocialAuth\Socialite\Two\User - */ - abstract protected function mapUserToObject(array $user); - - /** - * Redirect the user of the application to the provider's authentication screen. - * - * @return RedirectResponse - */ - public function redirect() - { - $state = null; - - if ($this->usesState()) { - $this->request->session()->put('state', $state = $this->getState()); - } - - if ($this->usesPKCE()) { - $this->request->session()->put('code_verifier', $this->getCodeVerifier()); - } - - return new RedirectResponse($this->getAuthUrl($state)); - } - - /** - * Build the authentication URL for the provider from the given base URL. - * - * @param string $url - * @param string $state - * @return string - */ - protected function buildAuthUrlFromBase($url, $state) - { - return $url.'?'.http_build_query($this->getCodeFields($state), '', '&', $this->encodingType); - } - - /** - * Get the GET parameters for the code request. - * - * @param string|null $state - * @return array - */ - protected function getCodeFields($state = null) - { - $fields = [ - 'client_id' => $this->clientId, - 'redirect_uri' => $this->redirectUrl, - 'scope' => $this->formatScopes($this->getScopes(), $this->scopeSeparator), - 'response_type' => 'code', - ]; - - if ($this->usesState()) { - $fields['state'] = $state; - } - - if ($this->usesPKCE()) { - $fields['code_challenge'] = $this->getCodeChallenge(); - $fields['code_challenge_method'] = $this->getCodeChallengeMethod(); - } - - return array_merge($fields, $this->parameters); - } - - /** - * Format the given scopes. - * - * @param array $scopes - * @param string $scopeSeparator - * @return string - */ - protected function formatScopes(array $scopes, $scopeSeparator) - { - return implode($scopeSeparator, $scopes); - } - - /** - * {@inheritdoc} - */ - public function user() - { - if ($this->user) { - return $this->user; - } - - if ($this->hasInvalidState()) { - throw new InvalidStateException; - } - - $response = $this->getAccessTokenResponse($this->getCode()); - - $user = $this->getUserByToken(array_get($response, 'access_token')); - - return $this->userInstance($response, $user); - } - - /** - * Create a user instance from the given data. - * - * @param array $response - * @param array $user - * @return \Plugins\SocialAuth\Socialite\Two\User - */ - protected function userInstance(array $response, array $user) - { - $this->user = $this->mapUserToObject($user); - - return $this->user->setToken(array_get($response, 'access_token')) - ->setRefreshToken(array_get($response, 'refresh_token')) - ->setExpiresIn(array_get($response, 'expires_in')) - ->setApprovedScopes(explode($this->scopeSeparator, array_get($response, 'scope', ''))); - } - - /** - * Get a Social User instance from a known access token. - * - * @param string $token - * @return \Plugins\SocialAuth\Socialite\Two\User - */ - public function userFromToken($token) - { - $user = $this->mapUserToObject($this->getUserByToken($token)); - - return $user->setToken($token); - } - - /** - * Determine if the current request / session has a mismatching "state". - * - * @return bool - */ - protected function hasInvalidState() - { - - if(SUPPORTED_SOCIAL_STATELESS_ALLOW) return false; - - if ($this->isStateless()) { - return false; - } - - $state = $this->request->session()->pull('state'); - - return empty($state) || $this->request->input('state') !== $state; - } - - /** - * Get the access token response for the given code. - * - * @param string $code - * @return array - */ - public function getAccessTokenResponse($code) - { - $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::HEADERS => $this->getTokenHeaders($code), - RequestOptions::FORM_PARAMS => $this->getTokenFields($code), - ]); - - return json_decode($response->getBody(), true); - } - - /** - * Get the headers for the access token request. - * - * @param string $code - * @return array - */ - protected function getTokenHeaders($code) - { - return ['Accept' => 'application/json']; - } - - /** - * Get the POST fields for the token request. - * - * @param string $code - * @return array - */ - protected function getTokenFields($code) - { - $fields = [ - 'grant_type' => 'authorization_code', - 'client_id' => $this->clientId, - 'client_secret' => $this->clientSecret, - 'code' => $code, - 'redirect_uri' => $this->redirectUrl, - ]; - - if ($this->usesPKCE()) { - $fields['code_verifier'] = $this->request->session()->pull('code_verifier'); - } - - return array_merge($fields, $this->parameters); - } - - /** - * Refresh a user's access token with a refresh token. - * - * @param string $refreshToken - * @return \Plugins\SocialAuth\Socialite\Two\Token - */ - public function refreshToken($refreshToken) - { - $response = $this->getRefreshTokenResponse($refreshToken); - - return new Token( - array_get($response, 'access_token'), - array_get($response, 'refresh_token'), - array_get($response, 'expires_in'), - explode($this->scopeSeparator, array_get($response, 'scope', '')) - ); - } - - /** - * Get the refresh token response for the given refresh token. - * - * @param string $refreshToken - * @return array - */ - protected function getRefreshTokenResponse($refreshToken) - { - return json_decode($this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::HEADERS => ['Accept' => 'application/json'], - RequestOptions::FORM_PARAMS => [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $refreshToken, - 'client_id' => $this->clientId, - 'client_secret' => $this->clientSecret, - ], - ])->getBody(), true); - } - - /** - * Get the code from the request. - * - * @return string - */ - protected function getCode() - { - return $this->request->input('code'); - } - - /** - * Merge the scopes of the requested access. - * - * @param array|string $scopes - * @return $this - */ - public function scopes($scopes) - { - $this->scopes = array_unique(array_merge($this->scopes, (array) $scopes)); - - return $this; - } - - /** - * Set the scopes of the requested access. - * - * @param array|string $scopes - * @return $this - */ - public function setScopes($scopes) - { - $this->scopes = array_unique((array) $scopes); - - return $this; - } - - /** - * Get the current scopes. - * - * @return array - */ - public function getScopes() - { - return $this->scopes; - } - - /** - * Set the redirect URL. - * - * @param string $url - * @return $this - */ - public function redirectUrl($url) - { - $this->redirectUrl = $url; - - return $this; - } - - /** - * Get a instance of the Guzzle HTTP client. - * - * @return \GuzzleHttp\Client - */ - protected function getHttpClient() - { - if (is_null($this->httpClient)) { - $this->httpClient = new Client($this->guzzle); - } - - return $this->httpClient; - } - - /** - * Set the Guzzle HTTP client instance. - * - * @param \GuzzleHttp\Client $client - * @return $this - */ - public function setHttpClient(Client $client) - { - $this->httpClient = $client; - - return $this; - } - - /** - * Set the request instance. - * - * @param Request $request - * @return $this - */ - public function setRequest(Request $request) - { - $this->request = $request; - - return $this; - } - - /** - * Determine if the provider is operating with state. - * - * @return bool - */ - protected function usesState() - { - return ! $this->stateless; - } - - /** - * Determine if the provider is operating as stateless. - * - * @return bool - */ - protected function isStateless() - { - return $this->stateless; - } - - /** - * Indicates that the provider should operate as stateless. - * - * @return $this - */ - public function stateless() - { - $this->stateless = true; - - return $this; - } - - /** - * Get the string used for session state. - * - * @return string - */ - protected function getState() - { - return str_random(40); - } - - /** - * Determine if the provider uses PKCE. - * - * @return bool - */ - protected function usesPKCE() - { - return $this->usesPKCE; - } - - /** - * Enables PKCE for the provider. - * - * @return $this - */ - public function enablePKCE() - { - $this->usesPKCE = true; - - return $this; - } - - /** - * Generates a random string of the right length for the PKCE code verifier. - * - * @return string - */ - protected function getCodeVerifier() - { - return str_random(96); - } - - /** - * Generates the PKCE code challenge based on the PKCE code verifier in the session. - * - * @return string - */ - protected function getCodeChallenge() - { - $hashed = hash('sha256', $this->request->session()->get('code_verifier'), true); - - return rtrim(strtr(base64_encode($hashed), '+/', '-_'), '='); - } - - /** - * Returns the hash method used to calculate the PKCE code challenge. - * - * @return string - */ - protected function getCodeChallengeMethod() - { - return 'S256'; - } - - /** - * Set the custom parameters of the request. - * - * @param array $parameters - * @return $this - */ - public function with(array $parameters) - { - $this->parameters = $parameters; - - return $this; - } -} diff --git a/plugins/SocialAuth/Socialite/Two/BitbucketProvider.php b/plugins/SocialAuth/Socialite/Two/BitbucketProvider.php deleted file mode 100644 index c6cc13e..0000000 --- a/plugins/SocialAuth/Socialite/Two/BitbucketProvider.php +++ /dev/null @@ -1,113 +0,0 @@ -buildAuthUrlFromBase('https://bitbucket.org/site/oauth2/authorize', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://bitbucket.org/site/oauth2/access_token'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $response = $this->getHttpClient()->get('https://api.bitbucket.org/2.0/user', [ - RequestOptions::QUERY => ['access_token' => $token], - ]); - - $user = json_decode($response->getBody(), true); - - if (in_array('email', $this->scopes, true)) { - $user['email'] = $this->getEmailByToken($token); - } - - return $user; - } - - /** - * Get the email for the given access token. - * - * @param string $token - * @return string|null - */ - protected function getEmailByToken($token) - { - $emailsUrl = 'https://api.bitbucket.org/2.0/user/emails?access_token='.$token; - - try { - $response = $this->getHttpClient()->get($emailsUrl); - } catch (Exception $e) { - return; - } - - $emails = json_decode($response->getBody(), true); - - foreach ($emails['values'] as $email) { - if ($email['type'] === 'email' && $email['is_primary'] && $email['is_confirmed']) { - return $email['email']; - } - } - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - return (new User)->setRaw($user)->map([ - 'id' => $user['uuid'], - 'nickname' => $user['username'], - 'name' => array_get($user, 'display_name'), - 'email' => array_get($user, 'email'), - 'avatar' => array_get($user, 'links.avatar.href'), - ]); - } - - /** - * Get the access token for the given code. - * - * @param string $code - * @return string - */ - public function getAccessToken($code) - { - $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::AUTH => [$this->clientId, $this->clientSecret], - RequestOptions::HEADERS => ['Accept' => 'application/json'], - RequestOptions::FORM_PARAMS => $this->getTokenFields($code), - ]); - - return json_decode($response->getBody(), true)['access_token']; - } -} diff --git a/plugins/SocialAuth/Socialite/Two/FacebookProvider.php b/plugins/SocialAuth/Socialite/Two/FacebookProvider.php deleted file mode 100644 index ec0a1b5..0000000 --- a/plugins/SocialAuth/Socialite/Two/FacebookProvider.php +++ /dev/null @@ -1,282 +0,0 @@ -buildAuthUrlFromBase('https://www.facebook.com/'.$this->version.'/dialog/oauth', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return $this->graphUrl.'/'.$this->version.'/oauth/access_token'; - } - - /** - * {@inheritdoc} - */ - public function getAccessTokenResponse($code) - { - $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::FORM_PARAMS => $this->getTokenFields($code), - ]); - - $data = json_decode($response->getBody(), true); - - return array_add($data, 'expires_in', array_pull($data, 'expires')); - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $this->lastToken = $token; - - return $this->getUserByOIDCToken($token) ?? - $this->getUserFromAccessToken($token); - } - - /** - * Get user based on the OIDC token. - * - * @param string $token - * @return array - */ - protected function getUserByOIDCToken($token) - { - $kid = json_decode(base64_decode(explode('.', $token)[0]), true)['kid'] ?? null; - - if ($kid === null) { - return null; - } - - $data = (array) JWT::decode($token, $this->getPublicKeyOfOIDCToken($kid)); - - throw_if($data['aud'] !== $this->clientId, new Exception('Token has incorrect audience.')); - throw_if($data['iss'] !== 'https://www.facebook.com', new Exception('Token has incorrect issuer.')); - - $data['id'] = $data['sub']; - - if (isset($data['given_name'])) { - $data['first_name'] = $data['given_name']; - } - - if (isset($data['family_name'])) { - $data['last_name'] = $data['family_name']; - } - - return $data; - } - - /** - * Get the public key to verify the signature of OIDC token. - * - * @param string $id - * @return \Firebase\JWT\Key - */ - protected function getPublicKeyOfOIDCToken(string $kid) - { - $response = $this->getHttpClient()->get('https://limited.facebook.com/.well-known/oauth/openid/jwks/'); - - $key = array_first(json_decode($response->getBody()->getContents(), true)['keys'], function ($key) use ($kid) { - return $key['kid'] === $kid; - }); - - $key['n'] = new BigInteger(JWT::urlsafeB64Decode($key['n']), 256); - $key['e'] = new BigInteger(JWT::urlsafeB64Decode($key['e']), 256); - - return new Key((string) RSA::load($key), 'RS256'); - } - - /** - * Get user based on the access token. - * - * @param string $token - * @return array - */ - protected function getUserFromAccessToken($token) - { - $params = [ - 'access_token' => $token, - 'fields' => implode(',', $this->fields), - ]; - - if (! empty($this->clientSecret)) { - $params['appsecret_proof'] = hash_hmac('sha256', $token, $this->clientSecret); - } - - $response = $this->getHttpClient()->get($this->graphUrl.'/'.$this->version.'/me', [ - RequestOptions::HEADERS => [ - 'Accept' => 'application/json', - ], - RequestOptions::QUERY => $params, - ]); - - return json_decode($response->getBody(), true); - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - if (! isset($user['sub'])) { - $avatarUrl = $this->graphUrl.'/'.$this->version.'/'.$user['id'].'/picture'; - - $avatarOriginalUrl = $avatarUrl.'?width=1920'; - } - - return (new User)->setRaw($user)->map([ - 'id' => $user['id'], - 'nickname' => null, - 'name' => $user['name'] ?? null, - 'email' => $user['email'] ?? null, - 'avatar' => $avatarUrl ?? $user['picture'] ?? null, - 'avatar_original' => $avatarOriginalUrl ?? $user['picture'] ?? null, - 'profileUrl' => $user['link'] ?? null, - ]); - } - - /** - * {@inheritdoc} - */ - protected function getCodeFields($state = null) - { - $fields = parent::getCodeFields($state); - - if ($this->popup) { - $fields['display'] = 'popup'; - } - - if ($this->reRequest) { - $fields['auth_type'] = 'rerequest'; - } - - return $fields; - } - - /** - * Set the user fields to request from Facebook. - * - * @param array $fields - * @return $this - */ - public function fields(array $fields) - { - $this->fields = $fields; - - return $this; - } - - /** - * Set the dialog to be displayed as a popup. - * - * @return $this - */ - public function asPopup() - { - $this->popup = true; - - return $this; - } - - /** - * Re-request permissions which were previously declined. - * - * @return $this - */ - public function reRequest() - { - $this->reRequest = true; - - return $this; - } - - /** - * Get the last access token used. - * - * @return string|null - */ - public function lastToken() - { - return $this->lastToken; - } - - /** - * Specify which graph version should be used. - * - * @param string $version - * @return $this - */ - public function usingGraphVersion(string $version) - { - $this->version = $version; - - return $this; - } -} diff --git a/plugins/SocialAuth/Socialite/Two/GithubProvider.php b/plugins/SocialAuth/Socialite/Two/GithubProvider.php deleted file mode 100644 index 607eb7e..0000000 --- a/plugins/SocialAuth/Socialite/Two/GithubProvider.php +++ /dev/null @@ -1,108 +0,0 @@ -buildAuthUrlFromBase('https://github.com/login/oauth/authorize', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://github.com/login/oauth/access_token'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $userUrl = 'https://api.github.com/user'; - - $response = $this->getHttpClient()->get( - $userUrl, $this->getRequestOptions($token) - ); - - $user = json_decode($response->getBody(), true); - - if (in_array('user:email', $this->scopes, true)) { - $user['email'] = $this->getEmailByToken($token); - } - - return $user; - } - - /** - * Get the email for the given access token. - * - * @param string $token - * @return string|null - */ - protected function getEmailByToken($token) - { - $emailsUrl = 'https://api.github.com/user/emails'; - - try { - $response = $this->getHttpClient()->get( - $emailsUrl, $this->getRequestOptions($token) - ); - } catch (Exception $e) { - return; - } - - foreach (json_decode($response->getBody(), true) as $email) { - if ($email['primary'] && $email['verified']) { - return $email['email']; - } - } - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - return (new User)->setRaw($user)->map([ - 'id' => $user['id'], - 'nodeId' => $user['node_id'], - 'nickname' => $user['login'], - 'name' => array_get($user, 'name'), - 'email' => array_get($user, 'email'), - 'avatar' => $user['avatar_url'], - ]); - } - - /** - * Get the default options for an HTTP request. - * - * @param string $token - * @return array - */ - protected function getRequestOptions($token) - { - return [ - RequestOptions::HEADERS => [ - 'Accept' => 'application/vnd.github.v3+json', - 'Authorization' => 'token '.$token, - ], - ]; - } -} diff --git a/plugins/SocialAuth/Socialite/Two/GitlabProvider.php b/plugins/SocialAuth/Socialite/Two/GitlabProvider.php deleted file mode 100644 index 70da078..0000000 --- a/plugins/SocialAuth/Socialite/Two/GitlabProvider.php +++ /dev/null @@ -1,86 +0,0 @@ -host = rtrim($host, '/'); - } - - return $this; - } - - /** - * {@inheritdoc} - */ - protected function getAuthUrl($state) - { - return $this->buildAuthUrlFromBase($this->host.'/oauth/authorize', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return $this->host.'/oauth/token'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $response = $this->getHttpClient()->get($this->host.'/api/v3/user', [ - RequestOptions::QUERY => ['access_token' => $token], - ]); - - return json_decode($response->getBody(), true); - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - return (new User)->setRaw($user)->map([ - 'id' => $user['id'], - 'nickname' => $user['username'], - 'name' => $user['name'], - 'email' => $user['email'], - 'avatar' => $user['avatar_url'], - ]); - } -} diff --git a/plugins/SocialAuth/Socialite/Two/GoogleProvider.php b/plugins/SocialAuth/Socialite/Two/GoogleProvider.php deleted file mode 100644 index f810f64..0000000 --- a/plugins/SocialAuth/Socialite/Two/GoogleProvider.php +++ /dev/null @@ -1,95 +0,0 @@ -buildAuthUrlFromBase('https://accounts.google.com/o/oauth2/auth', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://www.googleapis.com/oauth2/v4/token'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $response = $this->getHttpClient()->get('https://www.googleapis.com/oauth2/v3/userinfo', [ - RequestOptions::QUERY => [ - 'prettyPrint' => 'false', - ], - RequestOptions::HEADERS => [ - 'Accept' => 'application/json', - 'Authorization' => 'Bearer '.$token, - ], - ]); - - return json_decode((string) $response->getBody(), true); - } - - /** - * {@inheritdoc} - */ - public function refreshToken($refreshToken) - { - $response = $this->getRefreshTokenResponse($refreshToken); - - return new Token( - array_get($response, 'access_token'), - array_get($response, 'refresh_token', $refreshToken), - array_get($response, 'expires_in'), - explode($this->scopeSeparator, array_get($response, 'scope', '')) - ); - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - // Deprecated: Fields added to keep backwards compatibility in 4.0. These will be removed in 5.0 - $user['id'] = array_get($user, 'sub'); - $user['verified_email'] = array_get($user, 'email_verified'); - $user['link'] = array_get($user, 'profile'); - - return (new User)->setRaw($user)->map([ - 'id' => array_get($user, 'sub'), - 'nickname' => array_get($user, 'nickname'), - 'name' => array_get($user, 'name'), - 'email' => array_get($user, 'email'), - 'avatar' => $avatarUrl = array_get($user, 'picture'), - 'avatar_original' => $avatarUrl, - ]); - } -} diff --git a/plugins/SocialAuth/Socialite/Two/InvalidStateException.php b/plugins/SocialAuth/Socialite/Two/InvalidStateException.php deleted file mode 100644 index bad9df7..0000000 --- a/plugins/SocialAuth/Socialite/Two/InvalidStateException.php +++ /dev/null @@ -1,10 +0,0 @@ -buildAuthUrlFromBase('https://www.linkedin.com/oauth/v2/authorization', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://www.linkedin.com/oauth/v2/accessToken'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - return $this->getBasicProfile($token); - } - - /** - * Get the basic profile fields for the user. - * - * @param string $token - * @return array - */ - protected function getBasicProfile($token) - { - $response = $this->getHttpClient()->get('https://api.linkedin.com/v2/userinfo', [ - RequestOptions::HEADERS => [ - 'Authorization' => 'Bearer '.$token, - 'X-RestLi-Protocol-Version' => '2.0.0', - ], - RequestOptions::QUERY => [ - 'projection' => '(sub,email,name,given_name,family_name,picture)', - ], - ]); - - return (array) json_decode($response->getBody(), true); - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - return (new User)->setRaw($user)->map([ - 'id' => $user['sub'], - 'nickname' => null, - 'name' => $user['name'], - 'first_name' => $user['given_name'], - 'last_name' => $user['family_name'], - 'email' => $user['email'] ?? null, - 'avatar' => $user['picture'] ?? null, - 'avatar_original' => $user['picture'] ?? null, - ]); - } -} diff --git a/plugins/SocialAuth/Socialite/Two/LinkedInProvider.php b/plugins/SocialAuth/Socialite/Two/LinkedInProvider.php deleted file mode 100644 index a518452..0000000 --- a/plugins/SocialAuth/Socialite/Two/LinkedInProvider.php +++ /dev/null @@ -1,133 +0,0 @@ -buildAuthUrlFromBase('https://www.linkedin.com/oauth/v2/authorization', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://www.linkedin.com/oauth/v2/accessToken'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $basicProfile = $this->getBasicProfile($token); - $emailAddress = $this->getEmailAddress($token); - - return array_merge($basicProfile, $emailAddress); - } - - /** - * Get the basic profile fields for the user. - * - * @param string $token - * @return array - */ - protected function getBasicProfile($token) - { - $fields = ['id', 'firstName', 'lastName', 'profilePicture(displayImage~:playableStreams)']; - - if (in_array('r_liteprofile', $this->getScopes())) { - array_push($fields, 'vanityName'); - } - - $response = $this->getHttpClient()->get('https://api.linkedin.com/v2/me', [ - RequestOptions::HEADERS => [ - 'Authorization' => 'Bearer '.$token, - 'X-RestLi-Protocol-Version' => '2.0.0', - ], - RequestOptions::QUERY => [ - 'projection' => '('.implode(',', $fields).')', - ], - ]); - - return (array) json_decode($response->getBody(), true); - } - - /** - * Get the email address for the user. - * - * @param string $token - * @return array - */ - protected function getEmailAddress($token) - { - $response = $this->getHttpClient()->get('https://api.linkedin.com/v2/emailAddress', [ - RequestOptions::HEADERS => [ - 'Authorization' => 'Bearer '.$token, - 'X-RestLi-Protocol-Version' => '2.0.0', - ], - RequestOptions::QUERY => [ - 'q' => 'members', - 'projection' => '(elements*(handle~))', - ], - ]); - - return (array) array_get((array) json_decode($response->getBody(), true), 'elements.0.handle~'); - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - $preferredLocale = array_get($user, 'firstName.preferredLocale.language').'_'.array_get($user, 'firstName.preferredLocale.country'); - $firstName = array_get($user, 'firstName.localized.'.$preferredLocale); - $lastName = array_get($user, 'lastName.localized.'.$preferredLocale); - - $images = (array) array_get($user, 'profilePicture.displayImage~.elements', []); - $avatar = array_first($images, function ($image) { - return ( - $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width'] ?? - $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['displaySize']['width'] - ) === 100; - }); - $originalAvatar = array_first($images, function ($image) { - return ( - $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width'] ?? - $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['displaySize']['width'] - ) === 800; - }); - - return (new User)->setRaw($user)->map([ - 'id' => $user['id'], - 'nickname' => null, - 'name' => $firstName.' '.$lastName, - 'first_name' => $firstName, - 'last_name' => $lastName, - 'email' => array_get($user, 'emailAddress'), - 'avatar' => array_get($avatar, 'identifiers.0.identifier'), - 'avatar_original' => array_get($originalAvatar, 'identifiers.0.identifier'), - ]); - } -} diff --git a/plugins/SocialAuth/Socialite/Two/ProviderInterface.php b/plugins/SocialAuth/Socialite/Two/ProviderInterface.php deleted file mode 100644 index 5f4e440..0000000 --- a/plugins/SocialAuth/Socialite/Two/ProviderInterface.php +++ /dev/null @@ -1,20 +0,0 @@ -buildAuthUrlFromBase('https://slack.com/openid/connect/authorize', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://slack.com/api/openid.connect.token'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $response = $this->getHttpClient()->get('https://slack.com/api/openid.connect.userInfo', [ - RequestOptions::HEADERS => ['Authorization' => 'Bearer '.$token], - ]); - - return json_decode($response->getBody(), true); - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - return (new User)->setRaw($user)->map([ - 'id' => array_get($user, 'sub'), - 'nickname' => null, - 'name' => array_get($user, 'name'), - 'email' => array_get($user, 'email'), - 'avatar' => array_get($user, 'picture'), - 'organization_id' => array_get($user, 'https://slack.com/team_id'), - ]); - } -} diff --git a/plugins/SocialAuth/Socialite/Two/SlackProvider.php b/plugins/SocialAuth/Socialite/Two/SlackProvider.php deleted file mode 100644 index 55b967d..0000000 --- a/plugins/SocialAuth/Socialite/Two/SlackProvider.php +++ /dev/null @@ -1,110 +0,0 @@ -scopeKey = 'scope'; - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getAuthUrl($state) - { - return $this->buildAuthUrlFromBase('https://slack.com/oauth/v2/authorize', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://slack.com/api/oauth.v2.access'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $response = $this->getHttpClient()->get('https://slack.com/api/users.identity', [ - RequestOptions::HEADERS => ['Authorization' => 'Bearer '.$token], - ]); - - return json_decode($response->getBody(), true); - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - return (new User)->setRaw($user)->map([ - 'id' => array_get($user, 'user.id'), - 'name' => array_get($user, 'user.name'), - 'email' => array_get($user, 'user.email'), - 'avatar' => array_get($user, 'user.image_512'), - 'organization_id' => array_get($user, 'team.id'), - ]); - } - - /** - * {@inheritdoc} - */ - protected function getCodeFields($state = null) - { - $fields = parent::getCodeFields($state); - - if ($this->scopeKey === 'user_scope') { - $fields['scope'] = ''; - $fields['user_scope'] = $this->formatScopes($this->scopes, $this->scopeSeparator); - } - - return $fields; - } - - /** - * {@inheritdoc} - */ - public function getAccessTokenResponse($code) - { - $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::HEADERS => $this->getTokenHeaders($code), - RequestOptions::FORM_PARAMS => $this->getTokenFields($code), - ]); - - $result = json_decode($response->getBody(), true); - - if ($this->scopeKey === 'user_scope') { - return $result['authed_user']; - } - - return $result; - } -} diff --git a/plugins/SocialAuth/Socialite/Two/Token.php b/plugins/SocialAuth/Socialite/Two/Token.php deleted file mode 100644 index 1e12889..0000000 --- a/plugins/SocialAuth/Socialite/Two/Token.php +++ /dev/null @@ -1,50 +0,0 @@ -token = $token; - $this->refreshToken = $refreshToken; - $this->expiresIn = $expiresIn; - $this->approvedScopes = $approvedScopes; - } -} diff --git a/plugins/SocialAuth/Socialite/Two/TwitterProvider.php b/plugins/SocialAuth/Socialite/Two/TwitterProvider.php deleted file mode 100644 index 25fd503..0000000 --- a/plugins/SocialAuth/Socialite/Two/TwitterProvider.php +++ /dev/null @@ -1,124 +0,0 @@ -buildAuthUrlFromBase('https://twitter.com/i/oauth2/authorize', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://api.twitter.com/2/oauth2/token'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $response = $this->getHttpClient()->get('https://api.twitter.com/2/users/me', [ - RequestOptions::HEADERS => ['Authorization' => 'Bearer '.$token], - RequestOptions::QUERY => ['user.fields' => 'profile_image_url'], - ]); - - return array_get(json_decode($response->getBody(), true), 'data'); - } - - /** - * {@inheritdoc} - */ - protected function mapUserToObject(array $user) - { - return (new User)->setRaw($user)->map([ - 'id' => $user['id'], - 'nickname' => $user['username'], - 'name' => $user['name'], - 'avatar' => $user['profile_image_url'], - ]); - } - - /** - * {@inheritdoc} - */ - public function getAccessTokenResponse($code) - { - $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::HEADERS => ['Accept' => 'application/json'], - RequestOptions::AUTH => [$this->clientId, $this->clientSecret], - RequestOptions::FORM_PARAMS => $this->getTokenFields($code), - ]); - - return json_decode($response->getBody(), true); - } - - /** - * {@inheritdoc} - */ - protected function getRefreshTokenResponse($refreshToken) - { - $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::HEADERS => ['Accept' => 'application/json'], - RequestOptions::AUTH => [$this->clientId, $this->clientSecret], - RequestOptions::FORM_PARAMS => [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $refreshToken, - 'client_id' => $this->clientId, - ], - ]); - - return json_decode($response->getBody(), true); - } - - /** - * {@inheritdoc} - */ - protected function getCodeFields($state = null) - { - $fields = parent::getCodeFields($state); - - if ($this->isStateless()) { - $fields['state'] = 'state'; - } - - return $fields; - } -} diff --git a/plugins/SocialAuth/Socialite/Two/User.php b/plugins/SocialAuth/Socialite/Two/User.php deleted file mode 100644 index 25f241b..0000000 --- a/plugins/SocialAuth/Socialite/Two/User.php +++ /dev/null @@ -1,88 +0,0 @@ -token = $token; - - return $this; - } - - /** - * Set the refresh token required to obtain a new access token. - * - * @param string $refreshToken - * @return $this - */ - public function setRefreshToken($refreshToken) - { - $this->refreshToken = $refreshToken; - - return $this; - } - - /** - * Set the number of seconds the access token is valid for. - * - * @param int $expiresIn - * @return $this - */ - public function setExpiresIn($expiresIn) - { - $this->expiresIn = $expiresIn; - - return $this; - } - - /** - * Set the scopes that were approved by the user during authentication. - * - * @param array $approvedScopes - * @return $this - */ - public function setApprovedScopes($approvedScopes) - { - $this->approvedScopes = $approvedScopes; - - return $this; - } -} diff --git a/plugins/SocialAuth/Socialite/Two/XProvider.php b/plugins/SocialAuth/Socialite/Two/XProvider.php deleted file mode 100644 index 79963e6..0000000 --- a/plugins/SocialAuth/Socialite/Two/XProvider.php +++ /dev/null @@ -1,37 +0,0 @@ -buildAuthUrlFromBase('https://x.com/i/oauth2/authorize', $state); - } - - /** - * {@inheritdoc} - */ - protected function getTokenUrl() - { - return 'https://api.x.com/2/oauth2/token'; - } - - /** - * {@inheritdoc} - */ - protected function getUserByToken($token) - { - $response = $this->getHttpClient()->get('https://api.x.com/2/users/me', [ - RequestOptions::HEADERS => ['Authorization' => 'Bearer '.$token], - RequestOptions::QUERY => ['user.fields' => 'profile_image_url'], - ]); - - return array_get(json_decode($response->getBody(), true), 'data'); - } -} diff --git a/plugins/SocialAuth/database/migrations/.gitkeep b/plugins/SocialAuth/database/migrations/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/SocialAuth/database/tenant-template/2026_07_12_000001_create_social_identities_table.php b/plugins/SocialAuth/database/tenant-template/2026_07_12_000001_create_social_identities_table.php deleted file mode 100644 index 8c58569..0000000 --- a/plugins/SocialAuth/database/tenant-template/2026_07_12_000001_create_social_identities_table.php +++ /dev/null @@ -1,50 +0,0 @@ -hasTable('social_identities')) { - return; - } - - $schema->create('social_identities', static function ($t) { - $t->id(); - $t->string('provider', 32); - $t->string('provider_user_id', 191); - $t->char('user_id', 31); - $t->string('email', 150)->nullable(); - $t->string('name', 120)->nullable(); - $t->string('avatar', 255)->nullable(); - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - $t->timestamp('updated_at')->nullable(); - - $t->unique(['provider', 'provider_user_id'], 'uniq_provider_account'); - $t->index(['user_id'], 'idx_user'); - - $t->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('social_identities'); - } -}; diff --git a/plugins/SocialAuth/module.json b/plugins/SocialAuth/module.json deleted file mode 100644 index 4630e28..0000000 --- a/plugins/SocialAuth/module.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "social-auth", - "version": "1.0.0", - "solves": "auth.social", - "type": "module", - - "requires": ["database.management", "user.management", "auth.identity", "http.client"], - "exposes": ["Plugins\\SocialAuth\\API\\Contracts\\SocialAuthServiceContract"], - - "routes": [ - { "method": "GET", "path": "/auth/social/{driver}", "handler": "Plugins\\SocialAuth\\Infrastructure\\Http\\Controllers\\SocialAuthController@redirect", "filters": ["throttle:20,1"] }, - { "method": "GET", "path": "/auth/social/{driver}/callback", "handler": "Plugins\\SocialAuth\\Infrastructure\\Http\\Controllers\\SocialAuthController@callback", "filters": ["throttle:20,1"] }, - { "method": "POST", "path": "/auth/social/{driver}/token", "handler": "Plugins\\SocialAuth\\Infrastructure\\Http\\Controllers\\SocialAuthController@token", "filters": ["throttle:10,1"] } - ], - "emits": [], - "listens": [], - - "documentation": "Social sign-in, end to end: GET /auth/social/{driver} redirects to the provider, the callback maps the profile onto a central user (linked identity -> email match -> create) and opens a platform session (web) or returns a JWT+refresh pair (?mode=token). POST /auth/social/{driver}/token verifies a native-SDK token (google access_token/id_token, apple identity_token vs JWKS) for mobile. Links live in central social_identities.", - - "config": [ - { "key": "SOCIAL_AUTH_BASE_URL", "type": "string", "required": false }, - { "key": "SOCIAL_AUTH_SUCCESS_REDIRECT", "type": "string", "required": false }, - { "key": "AUTH_MOBILE_ACCESS_TTL", "type": "int", "required": false }, - { "key": "GITHUB_CLIENT_ID", "type": "string", "required": false }, - { "key": "GITHUB_CLIENT_SECRET", "type": "string", "required": false }, - { "key": "GITHUB_REDIRECT_URI", "type": "string", "required": false }, - { "key": "GOOGLE_CLIENT_ID", "type": "string", "required": false }, - { "key": "GOOGLE_CLIENT_SECRET", "type": "string", "required": false }, - { "key": "GOOGLE_REDIRECT_URI", "type": "string", "required": false }, - { "key": "APPLE_CLIENT_ID", "type": "string", "required": false } - ] -} diff --git a/plugins/Storage/Infrastructure/LocalStorageAdapter.php b/plugins/Storage/Infrastructure/LocalStorageAdapter.php deleted file mode 100644 index d496853..0000000 --- a/plugins/Storage/Infrastructure/LocalStorageAdapter.php +++ /dev/null @@ -1,226 +0,0 @@ -root = $resolved === '' ? '/' : $resolved; - } - - public function store(string $contents, string $filename, string $path = '', string $visibility = 'private'): string - { - $relative = $this->join($path, $filename); - $absolute = $this->absolute($relative); - - $dir = dirname($absolute); - if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { - throw new \RuntimeException("Storage: unable to create directory [{$dir}]."); - } - - // Atomic write: temp file in the same dir, then rename over the target. - $temp = $dir . '/.' . bin2hex(random_bytes(8)) . '.tmp'; - $handle = fopen($temp, 'wb'); - if ($handle === false) { - throw new \RuntimeException("Storage: unable to open temp file in [{$dir}]."); - } - try { - flock($handle, LOCK_EX); - $expected = strlen($contents); - $written = fwrite($handle, $contents); - // Detect short writes (e.g. disk full) before publishing the file. - if ($written === false || $written !== $expected) { - throw new \RuntimeException("Storage: failed to write file [{$relative}] (disk full?)."); - } - fflush($handle); - // Durability: flush kernel buffers to disk so the rename publishes - // a fully-persisted file even across a crash/power loss. - if (function_exists('fsync')) { - @fsync($handle); - } - flock($handle, LOCK_UN); - } catch (\Throwable $e) { - fclose($handle); - @unlink($temp); - throw $e; - } - fclose($handle); - - if (!rename($temp, $absolute)) { - @unlink($temp); - throw new \RuntimeException("Storage: unable to persist file [{$relative}]."); - } - - chmod($absolute, $visibility === 'public' ? 0644 : 0600); - - return $relative; - } - - public function storeStream($resource, string $filename, string $path = '', string $visibility = 'private'): string - { - if (!is_resource($resource)) { - throw new \InvalidArgumentException('Storage: storeStream expects a readable resource.'); - } - - $relative = $this->join($path, $filename); - $absolute = $this->absolute($relative); - - $dir = dirname($absolute); - if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { - throw new \RuntimeException("Storage: unable to create directory [{$dir}]."); - } - - // Atomic write: stream-copy to a temp file in the same dir, then rename. - $temp = $dir . '/.' . bin2hex(random_bytes(8)) . '.tmp'; - $handle = fopen($temp, 'wb'); - if ($handle === false) { - throw new \RuntimeException("Storage: unable to open temp file in [{$dir}]."); - } - try { - flock($handle, LOCK_EX); - if (stream_copy_to_stream($resource, $handle) === false) { - throw new \RuntimeException("Storage: failed to stream file [{$relative}] (disk full?)."); - } - fflush($handle); - if (function_exists('fsync')) { - @fsync($handle); - } - flock($handle, LOCK_UN); - } catch (\Throwable $e) { - fclose($handle); - @unlink($temp); - throw $e; - } - fclose($handle); - - if (!rename($temp, $absolute)) { - @unlink($temp); - throw new \RuntimeException("Storage: unable to persist file [{$relative}]."); - } - - chmod($absolute, $visibility === 'public' ? 0644 : 0600); - - return $relative; - } - - public function get(string $path): string - { - $absolute = $this->absolute($path); - if (!is_file($absolute)) { - throw new \RuntimeException("Storage: file [{$path}] not found."); - } - $contents = file_get_contents($absolute); - if ($contents === false) { - throw new \RuntimeException("Storage: unable to read file [{$path}]."); - } - return $contents; - } - - public function readStream(string $path) - { - $absolute = $this->absolute($path); - if (!is_file($absolute)) { - throw new \RuntimeException("Storage: file [{$path}] not found."); - } - $handle = fopen($absolute, 'rb'); - if ($handle === false) { - throw new \RuntimeException("Storage: unable to open file [{$path}]."); - } - return $handle; - } - - public function temporaryUrl(string $path, int $expiresInSeconds = 3600): string - { - $relative = $this->normalise($path); - $base = rtrim($this->urlBase, '/'); - $url = ($base === '' ? '' : $base) . '/' . ltrim($relative, '/'); - - if ($this->urlSecret === '') { - return $url; - } - - $expires = time() + max(1, $expiresInSeconds); - $signature = hash_hmac('sha256', $relative . '|' . $expires, $this->urlSecret); - - return $url . '?expires=' . $expires . '&signature=' . $signature; - } - - public function exists(string $path): bool - { - return is_file($this->absolute($path)); - } - - public function delete(string $path): bool - { - $absolute = $this->absolute($path); - return !is_file($absolute) || unlink($absolute); - } - - /** - * Verify a signature previously produced by temporaryUrl(). Useful for a - * download controller. Uses hash_equals to avoid timing leaks. - */ - public function verifyTemporaryUrl(string $path, int $expires, string $signature): bool - { - if ($this->urlSecret === '' || $expires < time()) { - return false; - } - $expected = hash_hmac('sha256', $this->normalise($path) . '|' . $expires, $this->urlSecret); - return hash_equals($expected, $signature); - } - - // ── Path safety ────────────────────────────────────────────────────────── - - private function join(string $path, string $filename): string - { - $path = trim($path, '/'); - return $path === '' ? $filename : $path . '/' . $filename; - } - - /** Resolve a caller path to an absolute path inside the root, or throw. */ - private function absolute(string $path): string - { - return $this->root . '/' . $this->normalise($path); - } - - /** Reject traversal and normalise to a clean relative path. */ - private function normalise(string $path): string - { - $path = str_replace('\\', '/', $path); - $segments = []; - foreach (explode('/', $path) as $segment) { - if ($segment === '' || $segment === '.') { - continue; - } - if ($segment === '..') { - throw new \RuntimeException('Storage: path traversal is not allowed.'); - } - $segments[] = $segment; - } - return implode('/', $segments); - } -} diff --git a/plugins/Storage/Infrastructure/S3StorageAdapter.php b/plugins/Storage/Infrastructure/S3StorageAdapter.php deleted file mode 100644 index d75d08a..0000000 --- a/plugins/Storage/Infrastructure/S3StorageAdapter.php +++ /dev/null @@ -1,152 +0,0 @@ -fs = new Filesystem(new AwsS3V3Adapter($client, $bucket)); - } - - /** - * Build from configuration. $endpoint/$usePathStyle support non-AWS, - * S3-compatible providers (Spaces, R2, MinIO). - * - * Static credentials are passed ONLY when an explicit key is supplied. When - * $key is empty the 'credentials' entry is omitted entirely so the AWS SDK's - * default provider chain (IAM instance/task roles, env vars, SSO, ~/.aws) - * takes over — the standard EC2/ECS/EKS production setup. Passing empty-string - * credentials would override and break that chain. - */ - public static function fromConfig( - string $bucket, - string $region, - string $key, - string $secret, - ?string $endpoint = null, - bool $usePathStyle = false, - ): self { - $config = [ - 'version' => 'latest', - 'region' => $region, - ]; - if ($key !== '') { - $config['credentials'] = ['key' => $key, 'secret' => $secret]; - } - if ($endpoint !== null && $endpoint !== '') { - $config['endpoint'] = $endpoint; - $config['use_path_style_endpoint'] = $usePathStyle; - } - - return new self(new S3Client($config), $bucket); - } - - public function store(string $contents, string $filename, string $path = '', string $visibility = 'private'): string - { - $key = $this->join($path, $filename); - try { - $this->fs->write($key, $contents, [ - 'visibility' => $visibility === 'public' ? Visibility::PUBLIC : Visibility::PRIVATE, - ]); - } catch (FilesystemException $e) { - throw new \RuntimeException("Storage(S3): unable to store [{$key}]: {$e->getMessage()}", previous: $e); - } - return $key; - } - - public function storeStream($resource, string $filename, string $path = '', string $visibility = 'private'): string - { - if (!is_resource($resource)) { - throw new \InvalidArgumentException('Storage(S3): storeStream expects a readable resource.'); - } - $key = $this->join($path, $filename); - try { - $this->fs->writeStream($key, $resource, [ - 'visibility' => $visibility === 'public' ? Visibility::PUBLIC : Visibility::PRIVATE, - ]); - } catch (FilesystemException $e) { - throw new \RuntimeException("Storage(S3): unable to store [{$key}]: {$e->getMessage()}", previous: $e); - } - return $key; - } - - public function get(string $path): string - { - try { - return $this->fs->read($path); - } catch (FilesystemException $e) { - throw new \RuntimeException("Storage(S3): unable to read [{$path}]: {$e->getMessage()}", previous: $e); - } - } - - public function readStream(string $path) - { - try { - return $this->fs->readStream($path); - } catch (FilesystemException $e) { - throw new \RuntimeException("Storage(S3): unable to read [{$path}]: {$e->getMessage()}", previous: $e); - } - } - - public function temporaryUrl(string $path, int $expiresInSeconds = 3600): string - { - $command = $this->client->getCommand('GetObject', [ - 'Bucket' => $this->bucket, - 'Key' => ltrim($path, '/'), - ]); - $request = $this->client->createPresignedRequest($command, '+' . max(1, $expiresInSeconds) . ' seconds'); - - return (string) $request->getUri(); - } - - public function exists(string $path): bool - { - try { - return $this->fs->fileExists($path); - } catch (FilesystemException) { - return false; - } - } - - public function delete(string $path): bool - { - try { - $this->fs->delete($path); - return true; - } catch (FilesystemException) { - return false; - } - } - - private function join(string $path, string $filename): string - { - $path = trim($path, '/'); - return $path === '' ? $filename : $path . '/' . $filename; - } -} diff --git a/plugins/Storage/Provider.php b/plugins/Storage/Provider.php deleted file mode 100644 index 86bb684..0000000 --- a/plugins/Storage/Provider.php +++ /dev/null @@ -1,86 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [StoragePort::class]; - } - - public function register(ModuleContainer $container): void - { - if ($container->has(StoragePort::class)) { - return; // a project already provided StoragePort - } - - $driver = (string) storage_config('driver', 'local'); - - if ($driver === 's3') { - $bucket = (string) storage_config('s3.bucket', ''); - if ($bucket === '') { - return; // S3 selected but not configured — leave unbound - } - $container->singleton(StoragePort::class, static fn() => S3StorageAdapter::fromConfig( - bucket: $bucket, - region: (string) storage_config('s3.region', 'us-east-1'), - key: (string) storage_config('s3.key', ''), - secret: (string) storage_config('s3.secret', ''), - endpoint: storage_config('s3.endpoint'), - usePathStyle: (bool) storage_config('s3.use_path_style', false), - )); - return; - } - - $root = (string) storage_config('local.root', ''); - if ($root === '') { - return; // local driver not configured - } - $container->singleton(StoragePort::class, static fn() => new LocalStorageAdapter( - root: $root, - urlBase: (string) storage_config('local.url_base', ''), - urlSecret: (string) storage_config('local.url_secret', ''), - )); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - } -} diff --git a/plugins/Storage/Support/helpers.php b/plugins/Storage/Support/helpers.php deleted file mode 100644 index ab7d6b4..0000000 --- a/plugins/Storage/Support/helpers.php +++ /dev/null @@ -1,66 +0,0 @@ -/config/storage.php is DEEP-MERGED over the plugin default: - * overriding `s3.bucket` no longer discards the rest of the shipped config, - * which the previous project-file-replaces-plugin-file lookup did silently. - * - * @return mixed the whole config array, or a single (dotted) key's value - */ - function storage_config(?string $key = null, mixed $default = null): mixed - { - if ($key === null) { - $all = function_exists('config') ? config('storage') : null; - - return is_array($all) && $all !== [] ? $all : storage_config_fallback(); - } - - if (function_exists('config')) { - $value = config('storage.' . $key, $sentinel = new stdClass()); - if ($value !== $sentinel) { - return $value; - } - } - - // No manifest compiled (e.g. a unit test that never ran the BootPipeline). - $config = storage_config_fallback(); - foreach (explode('.', $key) as $segment) { - if (!is_array($config) || !array_key_exists($segment, $config)) { - return $default; - } - $config = $config[$segment]; - } - - return $config; - } -} - -if (!function_exists('storage_config_fallback')) { - /** - * The plugin's shipped defaults, used only when no config manifest exists. - * - * @return array - */ - function storage_config_fallback(): array - { - static $config = null; - - if ($config === null) { - $loaded = require __DIR__ . '/../config/storage.php'; - $config = is_array($loaded) ? $loaded : []; - } - - return $config; - } -} diff --git a/plugins/Storage/config/storage.php b/plugins/Storage/config/storage.php deleted file mode 100644 index eb4e71e..0000000 --- a/plugins/Storage/config/storage.php +++ /dev/null @@ -1,77 +0,0 @@ -/config/storage.php (project override — copy this file there) - * 2. plugins/Storage/config/storage.php (this file — framework default) - * - * Read it anywhere with the storage_config() helper: - * storage_config('driver'); // 'local' | 's3' - * storage_config('local.root'); // dotted access into a section - * storage_config('s3.bucket'); - */ -return [ - - /* - |-------------------------------------------------------------------------- - | Default Driver - |-------------------------------------------------------------------------- - | Which StoragePort adapter the plugin binds: 'local' (disk) or 's3' - | (AWS S3 / DigitalOcean Spaces / Cloudflare R2 / MinIO). A project that - | wires StoragePort itself in withPorts() overrides this entirely. - */ - 'driver' => strtolower((string) (env('STORAGE_DRIVER') ?: 'local')), - - /* - |-------------------------------------------------------------------------- - | Local Disk Driver - |-------------------------------------------------------------------------- - | root directory blobs are written under (required to enable). A RELATIVE - | STORAGE_ROOT (e.g. "userdata/storage") resolves against the active - | project root via Paths::project(); an absolute path is used as-is - | url_base public base URL prefix for temporaryUrl() (CDN/host) - | url_secret HMAC secret used to sign + verify expiring temporary URLs - */ - 'local' => [ - 'root' => (static function (): string { - $root = (string) (env('STORAGE_ROOT') ?: ''); - if ($root === '') { - return ''; - } - // Absolute paths (Unix "/…" or Windows "C:\…") are used verbatim; - // a relative path is resolved under the active project root so - // STORAGE_ROOT=userdata/storage Just Works per project. - $isAbsolute = $root[0] === '/' || (bool) preg_match('#^[A-Za-z]:[\\\\/]#', $root); - return $isAbsolute ? $root : Paths::project($root); - })(), - 'url_base' => (string) (env('STORAGE_URL_BASE') ?: ''), - 'url_secret' => (string) (env('STORAGE_URL_SECRET') ?: ''), - ], - - /* - |-------------------------------------------------------------------------- - | S3 / S3-Compatible Driver - |-------------------------------------------------------------------------- - | bucket / region the target bucket and its region (required to enable) - | key / secret static credentials — LEAVE EMPTY on EC2/ECS/EKS so the - | AWS default provider chain (IAM roles) is used instead - | endpoint custom endpoint for non-AWS providers (Spaces/R2/MinIO) - | use_path_style true for MinIO / path-style endpoints - */ - 's3' => [ - 'bucket' => (string) (env('STORAGE_S3_BUCKET') ?: ''), - 'region' => (string) (env('STORAGE_S3_REGION') ?: 'us-east-1'), - 'key' => (string) (env('STORAGE_S3_KEY') ?: ''), - 'secret' => (string) (env('STORAGE_S3_SECRET') ?: ''), - 'endpoint' => env('STORAGE_S3_ENDPOINT') ?: null, - 'use_path_style' => filter_var(env('STORAGE_S3_PATH_STYLE') ?: 'false', FILTER_VALIDATE_BOOL), - ], - -]; diff --git a/plugins/Storage/module.json b/plugins/Storage/module.json deleted file mode 100644 index d585bed..0000000 --- a/plugins/Storage/module.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "storage", - "version": "1.0.0", - "solves": "storage.local", - "type": "module", - - "requires": [], - "exposes": ["AlfacodeTeam\\PhpServicePlatform\\Kernel\\Ports\\StoragePort"], - - "routes": [], - "emits": [], - "listens": [], - - "config": [ - { "key": "STORAGE_DRIVER", "type": "string", "required": false }, - { "key": "STORAGE_ROOT", "type": "string", "required": false }, - { "key": "STORAGE_URL_BASE", "type": "string", "required": false }, - { "key": "STORAGE_URL_SECRET", "type": "string", "required": false }, - { "key": "STORAGE_S3_BUCKET", "type": "string", "required": false }, - { "key": "STORAGE_S3_REGION", "type": "string", "required": false }, - { "key": "STORAGE_S3_KEY", "type": "string", "required": false }, - { "key": "STORAGE_S3_SECRET", "type": "string", "required": false }, - { "key": "STORAGE_S3_ENDPOINT", "type": "string", "required": false }, - { "key": "STORAGE_S3_PATH_STYLE","type": "bool", "required": false } - ] -} diff --git a/plugins/Tenancy/API/Contracts/InvitationServiceContract.php b/plugins/Tenancy/API/Contracts/InvitationServiceContract.php deleted file mode 100644 index 9538edf..0000000 --- a/plugins/Tenancy/API/Contracts/InvitationServiceContract.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ - public function myTenants(string $userId): array; - - /** - * True when the user has an active, routable seat in the tenant. - */ - public function isActiveMember(string $userId, string $tenantId): bool; - - /** - * The user's active membership in the tenant — seat AND tenant routable — - * or null when they hold none. Carries the seat's role, so authentication - * can hydrate the user's tenant role from the membership record. - */ - public function activeMember(string $userId, string $tenantId): ?TenantSummary; - - /** - * Select a tenant: verify active membership + routable tenant and record - * `tenant.switch` in the audit log. Returns the verified seat; the caller - * mints the tenant-scoped token (`tnt` claim) from it via the Auth module. - * - * @throws \Plugins\Tenancy\Domain\Exceptions\NotAMemberException (→ 403) - */ - public function selectTenant(string $userId, string $tenantId, ?string $ip = null): TenantSummary; -} diff --git a/plugins/Tenancy/API/Contracts/TenantAdminServiceContract.php b/plugins/Tenancy/API/Contracts/TenantAdminServiceContract.php deleted file mode 100644 index 4359e93..0000000 --- a/plugins/Tenancy/API/Contracts/TenantAdminServiceContract.php +++ /dev/null @@ -1,48 +0,0 @@ - - */ - public function list(): array; - - /** One tenant by id, or null when it does not exist. */ - public function get(string $tenantId): ?TenantDetail; - - /** - * Provision a brand new tenant. - * - * @param array{name:string,slug:string,driver:string,db_host:string,db_port:int,db_name:string,db_user:string,db_password:string} $input - */ - public function create(array $input): TenantDetail; - - /** - * Update safe metadata only (name, slug, status). Unknown/absent keys are - * left untouched. - * - * @param array{name?:string,slug?:string,status?:string} $input - */ - public function update(string $tenantId, array $input): TenantDetail; - - /** De-provision a tenant: drop its DB user, optionally its database, and the row. */ - public function delete(string $tenantId, bool $dropDatabase = false): void; -} diff --git a/plugins/Tenancy/API/Contracts/TenantConnectionResolverContract.php b/plugins/Tenancy/API/Contracts/TenantConnectionResolverContract.php deleted file mode 100644 index c17f1a8..0000000 --- a/plugins/Tenancy/API/Contracts/TenantConnectionResolverContract.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - public function list(string $tenantId): array; - - /** - * Register a new host for the tenant in Pending state and return the DNS - * proof-of-ownership instructions. Optionally pin an expected A target. - * - * @throws \Plugins\Tenancy\Domain\Exceptions\InvalidHostnameException - * @throws \Plugins\Tenancy\Domain\Exceptions\HostConflictException - */ - public function add(string $tenantId, string $hostname, ?string $expectedIp = null): HostVerificationInstructions; - - /** - * The DNS challenge for an existing Pending host (re-show in the UI). - * - * @throws \Plugins\Tenancy\Domain\Exceptions\HostNotFoundException - */ - public function instructions(string $tenantId, int $hostId): HostVerificationInstructions; - - /** - * Run the ownership check: scan the host's live DNS for the verification - * token (TXT) and, when configured, the expected A record. On success the - * host is promoted to Verified and becomes routable; on failure it is marked - * Failed with the observed records returned for diagnostics. - * - * @throws \Plugins\Tenancy\Domain\Exceptions\HostNotFoundException - */ - public function verify(string $tenantId, int $hostId): HostVerificationResult; - - /** - * Promote a VERIFIED host to the tenant's primary (canonical) host; demotes - * any previous primary. The redirect target the app should canonicalise to. - * - * @throws \Plugins\Tenancy\Domain\Exceptions\HostNotFoundException - */ - public function makePrimary(string $tenantId, int $hostId): TenantHost; - - /** - * Remove (soft-delete) a host so it stops routing immediately. - * - * @throws \Plugins\Tenancy\Domain\Exceptions\HostNotFoundException - */ - public function remove(string $tenantId, int $hostId): void; -} diff --git a/plugins/Tenancy/API/Contracts/TenantRegistryContract.php b/plugins/Tenancy/API/Contracts/TenantRegistryContract.php deleted file mode 100644 index f9c6384..0000000 --- a/plugins/Tenancy/API/Contracts/TenantRegistryContract.php +++ /dev/null @@ -1,34 +0,0 @@ - - */ - public function listByStatus(int $status): array; - - /** Drop any cached copy of a tenant (call after registry mutations). */ - public function forget(string $tenantId): void; -} diff --git a/plugins/Tenancy/API/DTOs/HostVerificationInstructions.php b/plugins/Tenancy/API/DTOs/HostVerificationInstructions.php deleted file mode 100644 index eed10f2..0000000 --- a/plugins/Tenancy/API/DTOs/HostVerificationInstructions.php +++ /dev/null @@ -1,45 +0,0 @@ - "" - * — or, for an apex they cannot add a sub-label TXT to — - * TXT "" - * - * When an expected A target is configured, the verifier ALSO confirms the host - * resolves to {@see $expectedIp}. - */ -final readonly class HostVerificationInstructions -{ - public function __construct( - public string $hostname, - public string $txtRecordName, - public string $txtRecordValue, - public ?string $expectedIp = null, - ) {} - - /** @return array */ - public function toArray(): array - { - return [ - 'hostname' => $this->hostname, - 'dns_record' => [ - 'type' => 'TXT', - 'name' => $this->txtRecordName, - 'value' => $this->txtRecordValue, - 'ttl' => 300, - ], - 'expected_ip' => $this->expectedIp, - 'instructions' => - "Add a TXT record \"{$this->txtRecordName}\" with value " - . "\"{$this->txtRecordValue}\" at your DNS provider, then verify.", - ]; - } -} diff --git a/plugins/Tenancy/API/DTOs/HostVerificationResult.php b/plugins/Tenancy/API/DTOs/HostVerificationResult.php deleted file mode 100644 index f6503a4..0000000 --- a/plugins/Tenancy/API/DTOs/HostVerificationResult.php +++ /dev/null @@ -1,51 +0,0 @@ - */ - public function toArray(): array - { - return [ - 'hostname' => $this->hostname, - 'verified' => $this->verified, - 'status' => $this->status, - 'reason' => $this->reason, - 'observed' => [ - 'txt' => $this->foundTxt, - 'ips' => $this->foundIps, - ], - ]; - } -} diff --git a/plugins/Tenancy/API/DTOs/InvitationResult.php b/plugins/Tenancy/API/DTOs/InvitationResult.php deleted file mode 100644 index 8e98823..0000000 --- a/plugins/Tenancy/API/DTOs/InvitationResult.php +++ /dev/null @@ -1,35 +0,0 @@ - */ - public function toArray(): array - { - return [ - 'inviteId' => $this->inviteId, - 'tenantId' => $this->tenantId, - 'email' => $this->email, - 'role' => $this->role, - 'token' => $this->token, - 'expiresAt' => $this->expiresAt, - ]; - } -} diff --git a/plugins/Tenancy/API/DTOs/TenantDetail.php b/plugins/Tenancy/API/DTOs/TenantDetail.php deleted file mode 100644 index 6b62c22..0000000 --- a/plugins/Tenancy/API/DTOs/TenantDetail.php +++ /dev/null @@ -1,62 +0,0 @@ -tenantId, - name: $t->name, - slug: $t->slug, - dbDriver: $t->dbDriver, - dbHost: $t->dbHost, - dbPort: $t->dbPort, - dbName: $t->dbName, - dbUsername: $t->dbUsername, - status: strtolower($t->status->name), - schemaVersion: $t->schemaVersion, - ); - } - - /** @return array */ - public function toArray(): array - { - return [ - 'tenantId' => $this->tenantId, - 'name' => $this->name, - 'slug' => $this->slug, - 'dbDriver' => $this->dbDriver, - 'dbHost' => $this->dbHost, - 'dbPort' => $this->dbPort, - 'dbName' => $this->dbName, - 'dbUsername' => $this->dbUsername, - 'status' => $this->status, - 'schemaVersion' => $this->schemaVersion, - ]; - } -} diff --git a/plugins/Tenancy/API/DTOs/TenantSelection.php b/plugins/Tenancy/API/DTOs/TenantSelection.php deleted file mode 100644 index 32de9e6..0000000 --- a/plugins/Tenancy/API/DTOs/TenantSelection.php +++ /dev/null @@ -1,34 +0,0 @@ - */ - public function toArray(): array - { - return [ - 'token' => $this->token, - 'tokenType' => 'Bearer', - 'tenantId' => $this->tenantId, - 'role' => $this->role, - 'expiresIn' => $this->expiresIn, - ]; - } -} diff --git a/plugins/Tenancy/API/DTOs/TenantSummary.php b/plugins/Tenancy/API/DTOs/TenantSummary.php deleted file mode 100644 index e27fc0a..0000000 --- a/plugins/Tenancy/API/DTOs/TenantSummary.php +++ /dev/null @@ -1,48 +0,0 @@ -tenantId, - name: $m->tenantName, - slug: $m->tenantSlug, - role: $m->role, - status: strtolower($m->status->name), - joinedAt: $m->joinedAt()?->format(\DateTimeInterface::RFC3339), - ); - } - - /** @return array */ - public function toArray(): array - { - return [ - 'tenantId' => $this->tenantId, - 'name' => $this->name, - 'slug' => $this->slug, - 'role' => $this->role, - 'status' => $this->status, - 'joinedAt' => $this->joinedAt, - ]; - } -} diff --git a/plugins/Tenancy/API/IntegrationEvents/HostUnverifiedIntegrationEvent.php b/plugins/Tenancy/API/IntegrationEvents/HostUnverifiedIntegrationEvent.php deleted file mode 100644 index 15c7313..0000000 --- a/plugins/Tenancy/API/IntegrationEvents/HostUnverifiedIntegrationEvent.php +++ /dev/null @@ -1,53 +0,0 @@ -version = '1.0'; - } - - public function name(): string - { - return 'tenant.host.unverified'; - } - - public function version(): string - { - return $this->version; - } - - /** @return array */ - public function payload(): array - { - return [ - 'tenantId' => $this->tenantId, - 'hostId' => $this->hostId, - 'hostname' => $this->hostname, - 'reason' => $this->reason, - 'occurredAt' => $this->occurredAt, - 'version' => $this->version, - ]; - } -} diff --git a/plugins/Tenancy/API/IntegrationEvents/HostVerifiedIntegrationEvent.php b/plugins/Tenancy/API/IntegrationEvents/HostVerifiedIntegrationEvent.php deleted file mode 100644 index d253845..0000000 --- a/plugins/Tenancy/API/IntegrationEvents/HostVerifiedIntegrationEvent.php +++ /dev/null @@ -1,50 +0,0 @@ -version = '1.0'; - } - - public function name(): string - { - return 'tenant.host.verified'; - } - - public function version(): string - { - return $this->version; - } - - /** @return array */ - public function payload(): array - { - return [ - 'tenantId' => $this->tenantId, - 'hostId' => $this->hostId, - 'hostname' => $this->hostname, - 'occurredAt' => $this->occurredAt, - 'version' => $this->version, - ]; - } -} diff --git a/plugins/Tenancy/Application/Listeners/AssignTenantMembershipOnUserRegistered.php b/plugins/Tenancy/Application/Listeners/AssignTenantMembershipOnUserRegistered.php deleted file mode 100644 index 147f206..0000000 --- a/plugins/Tenancy/Application/Listeners/AssignTenantMembershipOnUserRegistered.php +++ /dev/null @@ -1,43 +0,0 @@ -payload(); - $userId = (string) ($payload['userId'] ?? ''); - $tenantId = (string) ($payload['tenantId'] ?? ''); - - if ($userId === '' || $tenantId === '') { - return; // no tenant context on this registration — nothing to assign - } - - $this->memberships->upsertActive($userId, $tenantId, self::DEFAULT_ROLE); - } -} diff --git a/plugins/Tenancy/Application/Ports/AuditReader.php b/plugins/Tenancy/Application/Ports/AuditReader.php deleted file mode 100644 index 2fb15ea..0000000 --- a/plugins/Tenancy/Application/Ports/AuditReader.php +++ /dev/null @@ -1,44 +0,0 @@ - Newest first across the whole trail. */ - public function recent(int $limit = 50, ?int $beforeId = null): array; - - /** @return list Newest first for one tenant. */ - public function forTenant(string $tenantId, int $limit = 50, ?int $beforeId = null): array; - - /** @return list Newest first for one user. */ - public function forUser(string $userId, int $limit = 50, ?int $beforeId = null): array; - - /** @return list Newest first for one action (e.g. 'tenant.switch'). */ - public function byAction(string $action, int $limit = 50, ?int $beforeId = null): array; - - /** A single entry by its public event id, or null. */ - public function find(string $eventId): ?AuditEntry; - - /** How many entries a tenant has accrued. */ - public function countForTenant(string $tenantId): int; - - /** - * Delete entries strictly older than the cutoff (retention / GDPR purge). - * - * @return int rows removed - */ - public function purgeOlderThan(\DateTimeImmutable $cutoff): int; -} diff --git a/plugins/Tenancy/Application/Ports/AuditSink.php b/plugins/Tenancy/Application/Ports/AuditSink.php deleted file mode 100644 index 48017e7..0000000 --- a/plugins/Tenancy/Application/Ports/AuditSink.php +++ /dev/null @@ -1,23 +0,0 @@ - $meta - */ - public function record( - string $action, - ?string $userId = null, - ?string $tenantId = null, - array $meta = [], - ?string $ip = null, - ): void; -} diff --git a/plugins/Tenancy/Application/Ports/AuditWriter.php b/plugins/Tenancy/Application/Ports/AuditWriter.php deleted file mode 100644 index 0dc8cfa..0000000 --- a/plugins/Tenancy/Application/Ports/AuditWriter.php +++ /dev/null @@ -1,27 +0,0 @@ - $meta - */ - public function write( - string $action, - ?string $userId = null, - ?string $tenantId = null, - array $meta = [], - ?string $ip = null, - ): void; -} diff --git a/plugins/Tenancy/Application/Ports/DnsResolver.php b/plugins/Tenancy/Application/Ports/DnsResolver.php deleted file mode 100644 index 2c14c8f..0000000 --- a/plugins/Tenancy/Application/Ports/DnsResolver.php +++ /dev/null @@ -1,31 +0,0 @@ - - */ - public function activeForUser(string $userId): array; - - /** A single membership (any status) for (user, tenant), or null. */ - public function find(string $userId, string $tenantId): ?Membership; -} diff --git a/plugins/Tenancy/Application/Ports/MembershipWriter.php b/plugins/Tenancy/Application/Ports/MembershipWriter.php deleted file mode 100644 index a8bdf22..0000000 --- a/plugins/Tenancy/Application/Ports/MembershipWriter.php +++ /dev/null @@ -1,19 +0,0 @@ - all hosts for a tenant (any status). */ - public function allForTenant(string $tenantId): array; - - /** A single host by id, scoped to the tenant, or null. */ - public function find(string $tenantId, int $hostId): ?TenantHost; - - /** True when the hostname is registered by ANY tenant (global uniqueness). */ - public function hostnameTaken(string $hostname): bool; - - /** Insert a Pending host; returns its new id. */ - public function insert(string $tenantId, string $hostname, ?string $ipAddress, string $verificationToken): int; - - /** Persist a verification outcome (status + verified_at). */ - public function markStatus(string $tenantId, int $hostId, int $status, ?string $verifiedAt): void; - - /** Make one host primary and demote every other host of the tenant. */ - public function setPrimary(string $tenantId, int $hostId): void; - - /** Soft-delete a host so it stops routing. */ - public function softDelete(string $tenantId, int $hostId): void; -} diff --git a/plugins/Tenancy/Application/Ports/TenantProvisioner.php b/plugins/Tenancy/Application/Ports/TenantProvisioner.php deleted file mode 100644 index b317624..0000000 --- a/plugins/Tenancy/Application/Ports/TenantProvisioner.php +++ /dev/null @@ -1,38 +0,0 @@ - Every tenant in the registry. */ - public function all(): array; - - /** One tenant by id, or null when it does not exist. */ - public function find(string $tenantId): ?Tenant; - - /** True when a tenant with this slug exists (optionally excluding one id). */ - public function slugExists(string $slug, ?string $exceptId = null): bool; - - /** Insert a new registry row (status carried on the entity). */ - public function insert(Tenant $tenant): void; - - /** Flip a provisioning tenant to active and stamp its schema version. */ - public function markActive(string $tenantId, int $schemaVersion): void; - - /** - * Update safe metadata. Null values are left untouched. - * - * @param int|null $status backing TenantStatus value - */ - public function updateMeta(string $tenantId, ?string $name, ?string $slug, ?int $status): void; - - /** Remove the registry row. */ - public function delete(string $tenantId): void; -} diff --git a/plugins/Tenancy/Application/Services/AuditService.php b/plugins/Tenancy/Application/Services/AuditService.php deleted file mode 100644 index b8e2e06..0000000 --- a/plugins/Tenancy/Application/Services/AuditService.php +++ /dev/null @@ -1,49 +0,0 @@ -writer->write($action, $userId, $tenantId, $meta, $ip); - } catch (\Throwable $e) { - // Best-effort: never let an audit write fail the action it records — - // but surface the failure to the log instead of discarding it. - $this->logger->error('Audit trail write failed', [ - 'action' => $action, - 'tenant_id' => $tenantId, - 'user_id' => $userId, - 'exception' => $e::class, - 'message' => $e->getMessage(), - ]); - } - } -} diff --git a/plugins/Tenancy/Application/Services/InvitationService.php b/plugins/Tenancy/Application/Services/InvitationService.php deleted file mode 100644 index e6e9635..0000000 --- a/plugins/Tenancy/Application/Services/InvitationService.php +++ /dev/null @@ -1,97 +0,0 @@ -invitations->pendingExists($tenantId, $email)) { - throw new ValidationException(['email' => 'A pending invitation already exists for this email.']); - } - - $inviteId = Token::ulid(); - $rawToken = Token::random(); - $expiresAt = (new \DateTimeImmutable())->add(new \DateInterval('PT' . max(60, $ttlSeconds) . 'S')); - - $this->invitations->create( - $inviteId, $tenantId, $email, $role, Token::hash($rawToken), $invitedBy, $expiresAt, - ); - - $this->audit->record('member.invite', $invitedBy, $tenantId, ['email' => $email, 'role' => $role]); - - return new InvitationResult( - inviteId: $inviteId, - tenantId: $tenantId, - email: $email, - role: $role, - token: $rawToken, - expiresAt: $expiresAt->format(\DateTimeInterface::RFC3339), - ); - } - - public function accept(string $rawToken, string $userId, string $userEmail, ?string $ip = null): string - { - $invitation = $this->invitations->findByTokenHash(Token::hash($rawToken)); - - if ($invitation === null || !$invitation->isAcceptable()) { - throw InvalidInvitationException::notUsable(); - } - if (!hash_equals($invitation->email, mb_strtolower(trim($userEmail)))) { - $this->audit->record('member.join_denied', $userId, $invitation->tenantId, ['reason' => 'email_mismatch'], $ip); - throw InvalidInvitationException::emailMismatch(); - } - - $this->memberships->upsertActive($userId, $invitation->tenantId, $invitation->role); - $this->invitations->markAccepted($invitation->inviteId); - - $this->audit->record('member.join', $userId, $invitation->tenantId, ['role' => $invitation->role], $ip); - - return $invitation->tenantId; - } - - public function revoke(string $rawToken): void - { - $invitation = $this->invitations->findByTokenHash(Token::hash($rawToken)); - if ($invitation === null) { - return; - } - - $this->invitations->markRevoked($invitation->inviteId); - $this->audit->record('member.invite_revoked', null, $invitation->tenantId, ['inviteId' => $invitation->inviteId]); - } -} diff --git a/plugins/Tenancy/Application/Services/MembershipService.php b/plugins/Tenancy/Application/Services/MembershipService.php deleted file mode 100644 index 5735991..0000000 --- a/plugins/Tenancy/Application/Services/MembershipService.php +++ /dev/null @@ -1,73 +0,0 @@ - TenantSummary::fromMembership($m), - $this->memberships->activeForUser($userId), - ); - } - - public function isActiveMember(string $userId, string $tenantId): bool - { - return $this->activeMember($userId, $tenantId) !== null; - } - - public function activeMember(string $userId, string $tenantId): ?TenantSummary - { - $membership = $this->memberships->find($userId, $tenantId); - - return $membership !== null && $membership->isRoutable() - ? TenantSummary::fromMembership($membership) - : null; - } - - public function selectTenant(string $userId, string $tenantId, ?string $ip = null): TenantSummary - { - $membership = $this->memberships->find($userId, $tenantId); - - if ($membership === null || !$membership->isRoutable()) { - $this->audit->record('tenant.switch_denied', $userId, $tenantId, [], $ip); - throw NotAMemberException::for($userId, $tenantId); - } - - $this->audit->record('tenant.switch', $userId, $tenantId, ['role' => $membership->role], $ip); - - return TenantSummary::fromMembership($membership); - } -} diff --git a/plugins/Tenancy/Application/Services/TenantAdminService.php b/plugins/Tenancy/Application/Services/TenantAdminService.php deleted file mode 100644 index 5182ebe..0000000 --- a/plugins/Tenancy/Application/Services/TenantAdminService.php +++ /dev/null @@ -1,260 +0,0 @@ -requireAdmin(); - - return array_map( - static fn (Tenant $t): TenantDetail => TenantDetail::fromEntity($t), - $this->store->all(), - ); - } - - public function get(string $tenantId): ?TenantDetail - { - $this->requireAdmin(); - - $tenant = $this->store->find($tenantId); - - return $tenant === null ? null : TenantDetail::fromEntity($tenant); - } - - public function create(array $input): TenantDetail - { - $this->requireAdmin(); - - $name = trim((string) ($input['name'] ?? '')); - $slug = strtolower(trim((string) ($input['slug'] ?? ''))); - $driver = strtolower(trim((string) ($input['driver'] ?? 'mysql'))); - $dbHost = trim((string) ($input['db_host'] ?? '127.0.0.1')) ?: '127.0.0.1'; - $dbPort = (int) ($input['db_port'] ?? 0) ?: $this->defaultPort($driver); - $dbName = trim((string) ($input['db_name'] ?? '')); - $dbUser = trim((string) ($input['db_user'] ?? '')); - $dbPass = (string) ($input['db_password'] ?? ''); - - $errors = []; - if ($name === '') { - $errors['name'] = 'A name is required.'; - } - if (!preg_match('/^[a-z0-9-]+$/', $slug)) { - $errors['slug'] = 'Use ^[a-z0-9-]+$ only.'; - } - if (!in_array($driver, self::DRIVERS, true)) { - $errors['driver'] = "Use 'mysql', 'pgsql' or 'sqlsrv'."; - } - if (!preg_match('/^[A-Za-z0-9_]+$/', $dbName)) { - $errors['db_name'] = 'Letters, digits and underscore only.'; - } - if (!preg_match('/^[A-Za-z0-9_]+$/', $dbUser)) { - $errors['db_user'] = 'Letters, digits and underscore only.'; - } - if (!preg_match('/^[A-Za-z0-9_.:\-]+$/', $dbHost)) { - $errors['db_host'] = 'Hostname/IP characters only.'; - } - if ($errors !== []) { - throw new ValidationException($errors); - } - if ($this->store->slugExists($slug)) { - throw new ValidationException(['slug' => 'A tenant with this slug already exists.']); - } - - // Provisioning entity — password stored ENCRYPTED, status=provisioning. - $tenant = Tenant::create( - tenantId: Token::ulid(), - name: $name, - slug: $slug, - dbDriver: $driver, - dbHost: $dbHost, - dbPort: $dbPort, - dbName: $dbName, - dbUsername: $dbUser, - dbPasswordEnc: $this->crypto->encryptString($dbPass), - status: TenantStatus::Provisioning, - schemaVersion: 0, - ); - - $dbPreExisted = $this->provisioner->databaseExists($tenant); - - try { - $this->store->insert($tenant); - $this->provisioner->provision($tenant, $dbPass, $dbPreExisted); - $this->store->markActive($tenant->tenantId, schemaVersion: 1); - } catch (\Throwable $e) { - // Compensate: undo infra we created, then the registry row. - $this->provisioner->teardown($tenant, dropDatabase: !$dbPreExisted); - try { - $this->store->delete($tenant->tenantId); - } catch (\Throwable) { - } - - throw new ServiceException( - 'tenancy.create.failed', - layer: 'service.tenancy.admin', - context: ['slug' => $slug], - previous: $e, - ); - } - - $this->registry->forget($tenant->tenantId); - - return $this->get($tenant->tenantId) ?? throw new ServiceException( - 'tenancy.create.missing_after_commit', - layer: 'service.tenancy.admin', - ); - } - - public function update(string $tenantId, array $input): TenantDetail - { - $this->requireAdmin(); - - if ($this->store->find($tenantId) === null) { - throw new ServiceException('tenancy.not_found', layer: 'service.tenancy.admin', context: ['id' => $tenantId]); - } - - $name = null; - $slug = null; - $status = null; - $errors = []; - - if (array_key_exists('name', $input)) { - $candidate = trim((string) $input['name']); - if ($candidate === '') { - $errors['name'] = 'A name is required.'; - } else { - $name = $candidate; - } - } - - if (array_key_exists('slug', $input)) { - $candidate = strtolower(trim((string) $input['slug'])); - if (!preg_match('/^[a-z0-9-]+$/', $candidate)) { - $errors['slug'] = 'Use ^[a-z0-9-]+$ only.'; - } elseif ($this->store->slugExists($candidate, exceptId: $tenantId)) { - $errors['slug'] = 'Another tenant already uses this slug.'; - } else { - $slug = $candidate; - } - } - - if (array_key_exists('status', $input)) { - $resolved = $this->statusFromName((string) $input['status']); - if ($resolved === null) { - $errors['status'] = 'Unknown status.'; - } else { - $status = $resolved->value; - } - } - - if ($errors !== []) { - throw new ValidationException($errors); - } - - $this->store->updateMeta($tenantId, $name, $slug, $status); - $this->registry->forget($tenantId); - - return $this->get($tenantId) ?? throw new ServiceException('tenancy.not_found', layer: 'service.tenancy.admin', context: ['id' => $tenantId]); - } - - public function delete(string $tenantId, bool $dropDatabase = false): void - { - $this->requireAdmin(); - - $tenant = $this->store->find($tenantId); - if ($tenant === null) { - throw new ServiceException('tenancy.not_found', layer: 'service.tenancy.admin', context: ['id' => $tenantId]); - } - - $failed = $this->provisioner->teardown($tenant, dropDatabase: $dropDatabase); - $this->store->delete($tenantId); - $this->registry->forget($tenantId); - - if ($failed > 0) { - throw new ServiceException( - 'tenancy.delete.partial', - layer: 'service.tenancy.admin', - context: ['tenantId' => $tenantId, 'failedSteps' => $failed], - ); - } - } - - /** - * Control-plane authorization. Tenant management provisions real databases - * and mutates the central registry, so it is restricted to platform admins. - * Enforced HERE (not only in the controller) so any caller of the published - * contract is gated — the HTTP guard is just a cheap early reject. - */ - private function requireAdmin(): void - { - if ($this->identity->isGuest() - || (!$this->identity->hasPermission(self::ADMIN_PERMISSION) - && !$this->identity->hasRole(self::ADMIN_ROLE))) { - throw new SecurityException( - 'tenancy.admin.forbidden', - layer: 'service.tenancy.admin', - ); - } - } - - private function defaultPort(string $driver): int - { - return match ($driver) { - 'pgsql' => 5432, - 'sqlsrv' => 1433, - default => 3306, - }; - } - - private function statusFromName(string $name): ?TenantStatus - { - return match (strtolower(trim($name))) { - 'active' => TenantStatus::Active, - 'provisioning' => TenantStatus::Provisioning, - 'suspended' => TenantStatus::Suspended, - 'deleted' => TenantStatus::Deleted, - default => null, - }; - } -} diff --git a/plugins/Tenancy/Application/Services/TenantHostService.php b/plugins/Tenancy/Application/Services/TenantHostService.php deleted file mode 100644 index 8e0c6f0..0000000 --- a/plugins/Tenancy/Application/Services/TenantHostService.php +++ /dev/null @@ -1,212 +0,0 @@ -hosts->allForTenant($tenantId); - } - - public function add(string $tenantId, string $hostname, ?string $expectedIp = null): HostVerificationInstructions - { - $host = Hostname::of($hostname); // validates + normalises (throws on bad input) - $ip = $this->normaliseIp($expectedIp); - - if ($this->maxHostsPerTenant > 0 && count($this->hosts->allForTenant($tenantId)) >= $this->maxHostsPerTenant) { - throw new HostQuotaExceededException($tenantId, $this->maxHostsPerTenant); - } - - if ($this->hosts->hostnameTaken($host->value)) { - throw HostConflictException::for($host->value); - } - - $token = Token::random(32); - $hostId = $this->hosts->insert($tenantId, $host->value, $ip, $token); - - $this->registry->forget($host->value); // a MISS may have been cached - $this->audit->record('tenant_host.added', null, $tenantId, ['host_id' => $hostId, 'hostname' => $host->value]); - - return $this->buildInstructions($host->value, $token, $ip); - } - - public function instructions(string $tenantId, int $hostId): HostVerificationInstructions - { - $host = $this->require($tenantId, $hostId); - - return $this->buildInstructions($host->hostname, $host->verificationToken, $host->ipAddress); - } - - public function verify(string $tenantId, int $hostId): HostVerificationResult - { - $host = $this->require($tenantId, $hostId); - - $expectedValue = $this->valuePrefix . $host->verificationToken; - $challengeName = $this->challengeName($host->hostname); - - // Scan the challenge sub-label first, then the apex as a fallback so an - // owner who can only edit apex TXT records can still prove control. - $foundTxt = array_values(array_unique(array_merge( - $this->dns->txt($challengeName), - $this->dns->txt($host->hostname), - ))); - - $txtMatches = $this->txtContains($foundTxt, $expectedValue); - - // Optional A-record pinning: only enforced when an expected IP was set. - $foundIps = $host->ipAddress !== null ? $this->dns->ips($host->hostname) : []; - $ipMatches = $host->ipAddress === null || in_array($host->ipAddress, $foundIps, true); - - if ($txtMatches && $ipMatches) { - $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - $this->hosts->markStatus($tenantId, $hostId, HostStatus::Verified->value, $now); - $this->registry->forget($host->hostname); - $this->audit->record('tenant_host.verified', null, $tenantId, ['host_id' => $hostId, 'hostname' => $host->hostname]); - - return HostVerificationResult::ok($host->hostname, $foundTxt, $foundIps); - } - - $reason = !$txtMatches - ? 'TXT verification record not found or value mismatch.' - : "Host does not resolve to the expected IP [{$host->ipAddress}]."; - - // A host that was ALREADY verified but no longer proves ownership is a - // revocation / potential takeover — demote to Failed and stop routing it - // immediately. A host still awaiting its first proof simply stays Pending - // (DNS propagation lag is not a failure), so the owner can keep retrying. - if ($host->isVerified()) { - $this->hosts->markStatus($tenantId, $hostId, HostStatus::Failed->value, null); - $this->registry->forget($host->hostname); - $this->audit->record('tenant_host.verification_revoked', null, $tenantId, ['host_id' => $hostId, 'reason' => $reason]); - } else { - $this->audit->record('tenant_host.verification_pending', null, $tenantId, ['host_id' => $hostId, 'reason' => $reason]); - } - - return HostVerificationResult::fail($host->hostname, $reason, $foundTxt, $foundIps); - } - - public function makePrimary(string $tenantId, int $hostId): TenantHost - { - $host = $this->require($tenantId, $hostId); - - if (!$host->isVerified()) { - // Cannot canonicalise to an unproven host. - throw HostNotFoundException::for($tenantId, (string) $hostId); - } - - $this->hosts->setPrimary($tenantId, $hostId); - $this->audit->record('tenant_host.primary_set', null, $tenantId, ['host_id' => $hostId, 'hostname' => $host->hostname]); - - return $this->require($tenantId, $hostId); - } - - public function remove(string $tenantId, int $hostId): void - { - $host = $this->require($tenantId, $hostId); - - $this->hosts->softDelete($tenantId, $hostId); - $this->registry->forget($host->hostname); - $this->audit->record('tenant_host.removed', null, $tenantId, ['host_id' => $hostId, 'hostname' => $host->hostname]); - } - - private function require(string $tenantId, int $hostId): TenantHost - { - $host = $this->hosts->find($tenantId, $hostId); - if ($host === null) { - throw HostNotFoundException::for($tenantId, (string) $hostId); - } - - return $host; - } - - private function buildInstructions(string $hostname, string $token, ?string $ip): HostVerificationInstructions - { - return new HostVerificationInstructions( - hostname: $hostname, - txtRecordName: $this->challengeName($hostname), - txtRecordValue: $this->valuePrefix . $token, - expectedIp: $ip, - ); - } - - private function challengeName(string $hostname): string - { - return $this->challengePrefix . '.' . $hostname; - } - - /** - * TXT values can be split into multiple strings or padded — compare both the - * raw value and a whitespace-trimmed copy, case-insensitively on the prefix. - * - * @param string[] $found - */ - private function txtContains(array $found, string $expected): bool - { - foreach ($found as $value) { - if (hash_equals($expected, trim($value))) { - return true; - } - } - - return false; - } - - private function normaliseIp(?string $ip): ?string - { - if ($ip === null || trim($ip) === '') { - return null; - } - - $ip = trim($ip); - - return filter_var($ip, FILTER_VALIDATE_IP) !== false ? $ip : null; - } -} diff --git a/plugins/Tenancy/Domain/Entities/AuditEntry.php b/plugins/Tenancy/Domain/Entities/AuditEntry.php deleted file mode 100644 index e22ad22..0000000 --- a/plugins/Tenancy/Domain/Entities/AuditEntry.php +++ /dev/null @@ -1,57 +0,0 @@ - $row */ - public static function fromRow(array $row): self - { - $metaRaw = $row['meta'] ?? null; - $meta = is_string($metaRaw) && $metaRaw !== '' - ? (json_decode($metaRaw, true) ?: []) - : []; - - $e = (new self())->forceFill([ - 'id' => (int) $row['id'], - 'eventId' => (string) $row['event_id'], - 'userId' => isset($row['user_id']) ? (string) $row['user_id'] : null, - 'tenantId' => isset($row['tenant_id']) ? (string) $row['tenant_id'] : null, - 'action' => (string) $row['action'], - 'ip' => isset($row['ip']) ? (string) $row['ip'] : null, - 'meta' => is_array($meta) ? $meta : [], - 'occurredAt' => (string) $row['occurred_at'], - ]); - $e->syncOriginal(); - - return $e; - } - - /** @return array */ - public function toArray(bool $onlyChanged = false): array - { - return [ - 'id' => $this->id, - 'event_id' => $this->eventId, - 'user_id' => $this->userId, - 'tenant_id' => $this->tenantId, - 'action' => $this->action, - 'ip' => $this->ip, - 'meta' => $this->meta, - 'occurred_at' => $this->occurredAt, - ]; - } -} diff --git a/plugins/Tenancy/Domain/Entities/Invitation.php b/plugins/Tenancy/Domain/Entities/Invitation.php deleted file mode 100644 index e1608a4..0000000 --- a/plugins/Tenancy/Domain/Entities/Invitation.php +++ /dev/null @@ -1,52 +0,0 @@ - $row */ - public static function fromRow(array $row): self - { - $i = (new self())->forceFill([ - 'inviteId' => (string) $row['invite_id'], - 'tenantId' => (string) $row['tenant_id'], - 'email' => (string) $row['email'], - 'role' => (string) $row['role'], - 'status' => InvitationStatus::from((int) $row['status']), - 'expiresAt' => new \DateTimeImmutable((string) $row['expires_at']), - 'invitedBy' => (string) $row['invited_by'], - ]); - $i->syncOriginal(); - - return $i; - } - - public function isExpired(?\DateTimeImmutable $now = null): bool - { - return ($now ?? new \DateTimeImmutable()) >= $this->expiresAt; - } - - /** Acceptable only when pending AND not past expiry. */ - public function isAcceptable(?\DateTimeImmutable $now = null): bool - { - return $this->status->isPending() && !$this->isExpired($now); - } -} diff --git a/plugins/Tenancy/Domain/Entities/Membership.php b/plugins/Tenancy/Domain/Entities/Membership.php deleted file mode 100644 index 87eb8e0..0000000 --- a/plugins/Tenancy/Domain/Entities/Membership.php +++ /dev/null @@ -1,80 +0,0 @@ - */ - protected array $casts = [ - 'joinedAt' => 'datetime' - ]; - public static function of( - string $userId, - string $tenantId, - string $tenantName, - string $tenantSlug, - string $role, - MembershipStatus $status, - TenantStatus $tenantStatus, - ): self { - $m = (new self())->forceFill([ - 'userId' => $userId, - 'tenantId' => $tenantId, - 'tenantName' => $tenantName, - 'tenantSlug' => $tenantSlug, - 'role' => $role, - 'status' => $status, - 'tenantStatus' => $tenantStatus, - ]); - $m->syncOriginal(); - - return $m; - } - - /** @param array $row */ - public static function fromRow(array $row): self - { - - - $m = (new self([ - 'joinedAt' => $row['joined_at'] - ]))->forceFill([ - 'userId' => (string) $row['user_id'], - 'tenantId' => (string) $row['tenant_id'], - 'tenantName' => (string) ($row['name'] ?? ''), - 'tenantSlug' => (string) ($row['slug'] ?? ''), - 'role' => (string) $row['role'], - 'status' => MembershipStatus::from((int) $row['status']), - 'tenantStatus' => TenantStatus::from((int) ($row['tenant_status'] ?? TenantStatus::Active->value)), - ]); - $m->syncOriginal(); - - return $m; - } - - /** Routable only when BOTH the seat and the tenant are active. */ - public function isRoutable(): bool - { - return $this->status->isActive() && $this->tenantStatus->isRoutable(); - } - public function joinedAt(): ?\DateTimeImmutable - { - return $this->getDate('joinedAt'); - } -} diff --git a/plugins/Tenancy/Domain/Entities/Tenant.php b/plugins/Tenancy/Domain/Entities/Tenant.php deleted file mode 100644 index c804da5..0000000 --- a/plugins/Tenancy/Domain/Entities/Tenant.php +++ /dev/null @@ -1,98 +0,0 @@ -forceFill([ - 'tenantId' => $tenantId, - 'name' => $name, - 'slug' => $slug, - 'dbDriver' => $dbDriver, - 'dbHost' => $dbHost, - 'dbPort' => $dbPort, - 'dbName' => $dbName, - 'dbUsername' => $dbUsername, - 'dbPasswordEnc' => $dbPasswordEnc, - 'status' => $status, - 'schemaVersion' => $schemaVersion, - 'dbShard' => $dbShard, - ]); - $t->syncOriginal(); - - return $t; - } - - /** - * Hydrate from a central-DB row. Reconstitution only — records no events. - * - * @param array $row - */ - public static function fromRow(array $row): self - { - $t = (new self())->forceFill([ - 'tenantId' => (string) $row['tenant_id'], - 'name' => (string) $row['name'], - 'slug' => (string) $row['slug'], - 'dbDriver' => (string) $row['db_driver'], - 'dbHost' => (string) $row['db_host'], - 'dbPort' => (int) $row['db_port'], - 'dbName' => (string) $row['db_name'], - 'dbUsername' => (string) $row['db_username'], - 'dbPasswordEnc' => (string) $row['db_password_enc'], - 'status' => TenantStatus::from((int) $row['status']), - 'schemaVersion' => (int) ($row['schema_version'] ?? 0), - 'dbShard' => isset($row['db_shard']) ? (string) $row['db_shard'] : null, - ]); - $t->syncOriginal(); - - return $t; - } - - /** Stable connection name in the ConnectionManager registry. */ - public function connectionName(): string - { - return 'tenant:' . $this->tenantId; - } -} diff --git a/plugins/Tenancy/Domain/Entities/TenantHost.php b/plugins/Tenancy/Domain/Entities/TenantHost.php deleted file mode 100644 index 0e7e33a..0000000 --- a/plugins/Tenancy/Domain/Entities/TenantHost.php +++ /dev/null @@ -1,78 +0,0 @@ - $row - */ - public static function fromRow(array $row): self - { - $h = (new self())->forceFill([ - 'hostId' => (int) ($row['host_id'] ?? 0), - 'tenantId' => (string) $row['tenant_id'], - 'hostname' => (string) $row['hostname'], - 'ipAddress' => isset($row['ip_address']) ? (string) $row['ip_address'] : null, - 'status' => HostStatus::from((int) ($row['status'] ?? 0)), - 'verificationToken' => (string) ($row['verification_token'] ?? ''), - 'isPrimary' => (bool) ($row['is_primary'] ?? false), - 'verifiedAt' => isset($row['verified_at']) ? (string) $row['verified_at'] : null, - 'createdAt' => isset($row['created_at']) ? (string) $row['created_at'] : null, - 'updatedAt' => isset($row['updated_at']) ? (string) $row['updated_at'] : null, - ]); - $h->syncOriginal(); - - return $h; - } - - public function isVerified(): bool - { - return $this->status->isRoutable(); - } - - /** - * Shape returned to the management UI. The verification token is safe to show - * the OWNER — it is the public DNS challenge they must publish — but is never - * a secret credential. - * - * @return array - */ - public function toArray(bool $onlyChanged = false): array - { - return [ - 'host_id' => $this->hostId, - 'tenant_id' => $this->tenantId, - 'hostname' => $this->hostname, - 'ip_address' => $this->ipAddress, - 'status' => $this->status->label(), - 'verification_token' => $this->verificationToken, - 'is_primary' => $this->isPrimary, - 'verified_at' => $this->verifiedAt, - 'created_at' => $this->createdAt, - 'updated_at' => $this->updatedAt, - ]; - } -} diff --git a/plugins/Tenancy/Domain/Exceptions/HostConflictException.php b/plugins/Tenancy/Domain/Exceptions/HostConflictException.php deleted file mode 100644 index 4f06d9f..0000000 --- a/plugins/Tenancy/Domain/Exceptions/HostConflictException.php +++ /dev/null @@ -1,19 +0,0 @@ - 'pending', - self::Verified => 'verified', - self::Failed => 'failed', - }; - } -} diff --git a/plugins/Tenancy/Domain/ValueObjects/Hostname.php b/plugins/Tenancy/Domain/ValueObjects/Hostname.php deleted file mode 100644 index a115290..0000000 --- a/plugins/Tenancy/Domain/ValueObjects/Hostname.php +++ /dev/null @@ -1,90 +0,0 @@ - 253) { - return false; - } - - // Accept IP literals — hosts may be pinned to a loopback / direct IP. - if (filter_var($host, FILTER_VALIDATE_IP) !== false) { - return true; - } - - foreach (explode('.', $host) as $label) { - if (!preg_match('/^(?!-)[a-z0-9-]{1,63}(?value; - } -} diff --git a/plugins/Tenancy/Domain/ValueObjects/InvitationStatus.php b/plugins/Tenancy/Domain/ValueObjects/InvitationStatus.php deleted file mode 100644 index d2dfb2c..0000000 --- a/plugins/Tenancy/Domain/ValueObjects/InvitationStatus.php +++ /dev/null @@ -1,23 +0,0 @@ -name = 'tenant:host:add'; - $this->description = 'Register a hostname for a tenant (optionally force-verified / primary)'; - - $this->addOption('tenant', 't', 'Tenant id', acceptsValue: true); - $this->addOption('slug', '', 'Tenant slug', acceptsValue: true); - $this->addOption('host', 'H', 'Hostname (FQDN, lower-case, no port)', acceptsValue: true); - $this->addOption('ip', '', 'Expected A/AAAA target for verification', acceptsValue: true); - $this->addOption('verified', '', 'Force-mark VERIFIED without a DNS check (dev/seed only)'); - $this->addOption('primary', '', 'Make this the canonical host (implies --verified)'); - } - - protected function handle(): int - { - $id = (string) $this->option('tenant'); - $slug = (string) $this->option('slug'); - $hostname = strtolower(trim((string) $this->option('host'))); - $ip = trim((string) $this->option('ip')); - $primary = $this->hasOption('primary'); - $verified = $primary || $this->hasOption('verified'); - - $interactive = $this->isInteractive(); - $central = $this->connections->default(); - - // Tenant — by flag, else the default recorded by tenant:create in - // var/tenants.json (validated against the registry; a stale hint is - // dropped), else pick from a Select of registered tenants. - if ($id !== '' || $slug !== '') { - $tenantId = $this->resolveTenantId($central, $id, $slug); - if ($tenantId === null) { - $this->error('Tenant not found in the registry.'); - return self::FAILURE; - } - } else { - $tenantId = null; - $fallback = TenantsFile::defaultTenant(); - if ($fallback !== null) { - $tenantId = $this->resolveTenantId($central, $fallback['tenant_id'], ''); - if ($tenantId !== null) { - $this->info("Using default tenant [{$fallback['slug']}] from " . TenantsFile::path() . '.'); - } else { - TenantsFile::forget($fallback['tenant_id']); - $this->warning('Recorded default tenant no longer exists in the registry — stale entry dropped from var/tenants.json.'); - } - } - if ($tenantId === null && $interactive) { - $tenantId = $this->selectTenant($central); - } - if ($tenantId === null) { - $this->error($interactive - ? 'No tenants in the registry — create one with tenant:create first.' - : 'Provide --tenant or --slug (no default tenant recorded in var/tenants.json).'); - return self::FAILURE; - } - } - - // Hostname — by flag, else prompt with inline validation. - if ($hostname === '' && $interactive) { - $hostname = strtolower(trim((string) (new TextInput('Hostname (FQDN, no port)')) - ->placeholder('app.example.com') - ->validate(static fn (string $v): ?string => trim($v) === '' ? 'A hostname is required.' : null) - ->run())); - } - if ($hostname === '') { - $this->error('Provide --host .'); - return self::FAILURE; - } - - // Expected A/AAAA target. No --ip + a terminal → pick from a Select - // (loopback shortcuts or a typed address); otherwise use the flag. - if ($ip === '' && $interactive) { - $ip = $this->promptIp(); - } - if ($ip !== '' && filter_var($ip, \FILTER_VALIDATE_IP) === false) { - $this->error("Invalid --ip [{$ip}] — must be a valid IPv4 or IPv6 address."); - return self::FAILURE; - } - - // Verified / primary — ask when not given on the command line. - if (!$verified && $interactive) { - $verified = $this->confirm('Mark this host VERIFIED now (skip DNS check)?', false); - } - if ($verified && !$primary && $interactive) { - $primary = $this->confirm('Set as the PRIMARY (canonical) host?', false); - } - - try { - $instructions = $this->hosts->add($tenantId, $hostname, $ip !== '' ? $ip : null); - } catch (\Throwable $e) { - $this->error("Could not add host: {$e->getMessage()}"); - return self::FAILURE; - } - $this->info("Host [{$hostname}] registered for tenant [{$tenantId}] (status=pending)."); - - if (!$verified) { - $this->alertInfo('DNS verification required', [ - "TXT {$instructions->txtRecordName}", - " {$instructions->txtRecordValue}", - 'Then run: tenant:host:verify (HTTP) or add --verified to seed it directly.', - ]); - return self::SUCCESS; - } - - // Seed shortcut — mark verified directly, bypassing the DNS challenge. - $hostId = $this->hostId($central, $tenantId, $hostname); - if ($hostId === null) { - $this->error('Host row vanished after insert — aborting.'); - return self::FAILURE; - } - - $central->execute( - 'UPDATE tenant_hosts SET status = :s, verified_at = :at WHERE host_id = :id', - ['s' => self::STATUS_VERIFIED, 'at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'id' => $hostId], - ); - $this->success("Host [{$hostname}] marked VERIFIED."); - - if ($primary) { - try { - $this->hosts->makePrimary($tenantId, $hostId); - $this->success("Host [{$hostname}] set as PRIMARY."); - } catch (\Throwable $e) { - $this->error("Could not set primary: {$e->getMessage()}"); - return self::FAILURE; - } - } - - return self::SUCCESS; - } - - /** - * Pick the expected verification IP from a Select: skip, a loopback - * shortcut, or a typed (validated) address. Returns '' for "skip". - */ - private function promptIp(): string - { - $none = 'None — skip IP pinning'; - $v6 = '::1 (IPv6 loopback)'; - $v4 = '127.0.0.1 (IPv4 loopback)'; - $custom = 'Enter a specific IP…'; - - $choice = $this->select('Expected IP for DNS A/AAAA verification?', [$none, $v6, $v4, $custom]); - - return match ($choice) { - $v6 => '::1', - $v4 => '127.0.0.1', - $custom => (string) (new TextInput('IP address')) - ->validate(static fn (string $v): ?string => - filter_var(trim($v), \FILTER_VALIDATE_IP) !== false ? null : 'Enter a valid IPv4/IPv6 address.') - ->run(), - default => '', - }; - } - - /** True only when STDIN is a real terminal — guards the Select prompt. */ - private function isInteractive(): bool - { - return \function_exists('stream_isatty') && @stream_isatty(\STDIN); - } - - /** Present registered tenants as a Select; returns the chosen tenant_id (null if none). */ - private function selectTenant(DatabasePort $central): ?string - { - $rows = $central->query('SELECT tenant_id, slug, name FROM tenants WHERE deleted_at IS NULL ORDER BY slug'); - if ($rows === []) { - return null; - } - - $choices = []; - foreach ($rows as $r) { - $choices["{$r['slug']} — {$r['name']} ({$r['tenant_id']})"] = (string) $r['tenant_id']; - } - - return $choices[$this->select('Select a tenant', array_keys($choices))] ?? null; - } - - private function resolveTenantId(DatabasePort $central, string $id, string $slug): ?string - { - $row = $id !== '' - ? $central->queryOne('SELECT tenant_id FROM tenants WHERE tenant_id = :id', ['id' => $id]) - : $central->queryOne('SELECT tenant_id FROM tenants WHERE slug = :slug', ['slug' => $slug]); - - return $row === null ? null : (string) $row['tenant_id']; - } - - private function hostId(DatabasePort $central, string $tenantId, string $hostname): ?int - { - $row = $central->queryOne( - 'SELECT host_id FROM tenant_hosts WHERE tenant_id = :t AND hostname = :h', - ['t' => $tenantId, 'h' => $hostname], - ); - - return $row === null ? null : (int) $row['host_id']; - } -} diff --git a/plugins/Tenancy/Infrastructure/Cli/Concerns/ManagesTenantDatabase.php b/plugins/Tenancy/Infrastructure/Cli/Concerns/ManagesTenantDatabase.php deleted file mode 100644 index 97729bf..0000000 --- a/plugins/Tenancy/Infrastructure/Cli/Concerns/ManagesTenantDatabase.php +++ /dev/null @@ -1,77 +0,0 @@ - - */ - protected function grantHosts(string $dbHost): array - { - $local = ['localhost', '127.0.0.1', '::1', '']; - - return in_array(strtolower($dbHost), $local, true) - ? ['localhost', '127.0.0.1', '::1'] - : [$dbHost]; - } - - /** Does the physical database already exist? (Driver-aware catalogue lookup.) */ - protected function databaseExists(DatabasePort $db, string $driver, string $dbName): bool - { - $row = match ($driver) { - 'pgsql' => $db->queryOne('SELECT 1 AS present FROM pg_database WHERE datname = :db', ['db' => $dbName]), - 'sqlsrv' => $db->queryOne('SELECT 1 AS present FROM sys.databases WHERE name = :db', ['db' => $dbName]), - default => $db->queryOne('SELECT 1 AS present FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = :db', ['db' => $dbName]), - }; - - return $row !== null; - } - - /** Drop the physical database (idempotent, driver-aware). */ - protected function dropDatabase(DatabasePort $db, string $driver, string $dbName): void - { - match ($driver) { - 'pgsql' => $db->execute("DROP DATABASE IF EXISTS \"{$dbName}\""), - 'sqlsrv' => $db->execute("IF DB_ID('{$dbName}') IS NOT NULL DROP DATABASE [{$dbName}]"), - default => $db->execute("DROP DATABASE IF EXISTS `{$dbName}`"), - }; - } - - /** Drop the tenant account (idempotent, across every grant host on MySQL). */ - protected function dropDatabaseUser(DatabasePort $db, string $driver, string $dbUser, string $dbHost): void - { - if ($driver === 'pgsql') { - $db->execute("DROP ROLE IF EXISTS \"{$dbUser}\""); - - return; - } - - if ($driver === 'sqlsrv') { - $login = str_replace(']', ']]', $dbUser); - $db->execute("IF EXISTS (SELECT 1 FROM sys.server_principals WHERE name = '{$dbUser}') DROP LOGIN [{$login}]"); - - return; - } - - // MySQL / MariaDB — one account per grant host. - foreach ($this->grantHosts($dbHost) as $host) { - $db->execute("DROP USER IF EXISTS '{$dbUser}'@'{$host}'"); - } - $db->execute('FLUSH PRIVILEGES'); - } -} diff --git a/plugins/Tenancy/Infrastructure/Cli/CreateTenantCommand.php b/plugins/Tenancy/Infrastructure/Cli/CreateTenantCommand.php deleted file mode 100644 index 15ea60c..0000000 --- a/plugins/Tenancy/Infrastructure/Cli/CreateTenantCommand.php +++ /dev/null @@ -1,421 +0,0 @@ - canonical driver token (the interactive picker list). */ - private const DRIVERS = [ - 'MySQL / MariaDB' => 'mysql', - 'PostgreSQL' => 'pgsql', - 'SQL Server' => 'sqlsrv', - ]; - - public function __construct( - private readonly DatabaseConnectionManagerContract $connections, - private readonly EncryptionPort $crypto, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this->name = 'tenant:create'; - $this->description = 'Provision a new tenant: registry row + isolated database + template migrations'; - - $this->addOption('name', '', 'Human-readable tenant name', acceptsValue: true); - $this->addOption('slug', '', 'DNS/db-safe slug (^[a-z0-9-]+$)', acceptsValue: true); - $this->addOption('driver', '', 'mysql|pgsql|sqlsrv', acceptsValue: true, default: 'mysql'); - $this->addOption('db-host', '', 'Tenant DB host', acceptsValue: true, default: '127.0.0.1'); - $this->addOption('db-port', '', 'Tenant DB port (default per driver: 3306/5432/1433)', acceptsValue: true, default: ''); - $this->addOption('db-name', '', 'Physical database name (e.g. tnt_acme)', acceptsValue: true); - $this->addOption('db-user', '', 'Tenant DB username', acceptsValue: true); - $this->addOption('db-password', '', 'Tenant DB password (stored encrypted)', acceptsValue: true, default: ''); - $this->addOption('template', '', 'Override template migrations path', acceptsValue: true); - } - - protected function handle(): int - { - $driver = strtolower((string) $this->option('driver', 'mysql')); - $name = (string) $this->option('name'); - $slug = strtolower((string) $this->option('slug')); - $dbName = (string) $this->option('db-name'); - $dbUser = (string) $this->option('db-user'); - $dbHost = (string) $this->option('db-host', '127.0.0.1'); - $dbPort = (string) $this->option('db-port'); - $dbPass = (string) $this->option('db-password', ''); - - // No required values on the command line → walk the operator through it - // one prompt at a time: pick the driver from a list FIRST, then collect - // the rest with driver-aware defaults. Needs a TTY; CI must pass flags. - if ($name === '' || $slug === '' || $dbName === '' || $dbUser === '') { - if (!$this->isInteractive()) { - $this->error('Required: --name --slug --db-name --db-user (or run in an interactive terminal to be prompted).'); - return self::FAILURE; - } - - // Driver FIRST — a radio list of the supported engines. - $radio = new RadioGroup('Select the database driver', array_keys(self::DRIVERS)); - $current = array_search($driver, self::DRIVERS, true); - if ($current !== false) { - $radio->default($current); - } - $driver = self::DRIVERS[(string) $radio->run()] ?? $driver; - - // Inline validators — the prompt blocks until the value is valid. - $idRule = static fn (string $v): ?string => - preg_match('/^[A-Za-z0-9_]+$/', $v) ? null : 'Letters, digits and underscore only.'; - - if ($name === '') { - $name = (string) (new TextInput('Tenant display name')) - ->placeholder('Acme Inc') - ->validate(static fn (string $v): ?string => trim($v) === '' ? 'A name is required.' : null) - ->run(); - } - if ($slug === '') { - $slug = strtolower((string) (new TextInput('Tenant slug')) - ->default($this->slugify($name)) - ->validate(static fn (string $v): ?string => - preg_match('/^[a-z0-9-]+$/', strtolower($v)) ? null : 'Use ^[a-z0-9-]+$ only.') - ->run()); - } - if ($dbName === '') { - $dbName = (string) (new TextInput('Physical database name')) - ->default('tnt_' . $this->identifier($slug)) - ->validate($idRule) - ->run(); - } - if ($dbUser === '') { - $dbUser = (string) (new TextInput('Database username')) - ->default($this->identifier($slug) . '_user') - ->validate($idRule) - ->run(); - } - if (!$this->hasOption('db-password')) { - $dbPass = (string) (new Password('Database password (stored encrypted)'))->showStrength()->run(); - } - $dbHost = (string) (new TextInput('Database host')) - ->default($dbHost !== '' ? $dbHost : '127.0.0.1') - ->run(); - $dbPort = (string) (int) (new NumberInput('Database port')) - ->integer() - ->min(1) - ->max(65535) - ->default((float) ($dbPort !== '' ? $dbPort : $this->defaultPort($driver))) - ->run(); - } - - if ($dbPort === '') { - $dbPort = $this->defaultPort($driver); - } - $dbPort = (int) $dbPort; - - if ($name === '' || $slug === '' || $dbName === '' || $dbUser === '') { - $this->error('Required: --name --slug --db-name --db-user'); - return self::FAILURE; - } - if (in_array($driver, ['sqlite', 'sqlite3'], true)) { - $this->error("SQLite is file-per-database with no users/roles — tenant:create's CREATE DATABASE/USER model does not apply. Provision SQLite tenants as one file per tenant instead."); - return self::FAILURE; - } - if (!in_array($driver, ['mysql', 'pgsql', 'sqlsrv'], true)) { - $this->error("Unsupported --driver [{$driver}] — use 'mysql', 'pgsql', or 'sqlsrv'."); - return self::FAILURE; - } - if (!preg_match('/^[a-z0-9-]+$/', $slug)) { - $this->error("Invalid slug [{$slug}] — must match ^[a-z0-9-]+$"); - return self::FAILURE; - } - if (!preg_match('/^[A-Za-z0-9_]+$/', $dbName)) { - $this->error("Unsafe --db-name [{$dbName}] — letters, digits, underscore only."); - return self::FAILURE; - } - if (!preg_match('/^[A-Za-z0-9_]+$/', $dbUser)) { - $this->error("Unsafe --db-user [{$dbUser}] — letters, digits, underscore only."); - return self::FAILURE; - } - if (!preg_match('/^[A-Za-z0-9_.:\-]+$/', $dbHost)) { - $this->error("Unsafe --db-host [{$dbHost}] — hostname/IP characters only."); - return self::FAILURE; - } - - // Interactive runs get a final confirmation with the resolved settings. - if ($this->isInteractive()) { - $this->alertInfo('Provision tenant', [ - "driver : {$driver}", - "name : {$name}", - "slug : {$slug}", - "database : {$dbName} @ {$dbHost}:{$dbPort}", - "username : {$dbUser}", - ]); - if (!$this->confirm('Create this tenant now?', true)) { - $this->warning('Aborted — nothing was provisioned.'); - return self::SUCCESS; - } - } - - $central = $this->connections->default(); - $tenantId = Token::ulid(); - - // Atomic provisioning. DDL (CREATE DATABASE/USER) can't be rolled back by - // a SQL transaction on MySQL, so on ANY failure we COMPENSATE: drop the - // user, drop the database (only if WE created it), delete the registry - // row — leaving the system exactly as it was before the command ran. - $dbPreExisted = $this->databaseExists($central, $driver, $dbName); - $dbCreated = false; - $userCreated = false; - - try { - // 1. Registry row (provisioning), password encrypted. - $central->execute( - 'INSERT INTO tenants - (tenant_id, name, slug, db_driver, db_host, db_port, db_name, - db_username, db_password_enc, status, schema_version) - VALUES (:id, :name, :slug, :driver, :host, :port, :db, :user, :pass, :status, 0)', - [ - 'id' => $tenantId, 'name' => $name, 'slug' => $slug, - 'driver' => $driver, 'host' => $dbHost, 'port' => $dbPort, - 'db' => $dbName, 'user' => $dbUser, - 'pass' => $this->crypto->encryptString($dbPass), - 'status' => TenantStatus::Provisioning->value, - ], - ); - $this->info("Registered tenant [{$tenantId}] (slug={$slug}, status=provisioning)."); - - // 2. Create the isolated database (idempotent, driver-aware). - if (!$dbPreExisted) { - if ($driver === 'pgsql') { - $central->execute("CREATE DATABASE \"{$dbName}\""); - } elseif ($driver === 'sqlsrv') { - $central->execute("IF DB_ID('{$dbName}') IS NULL CREATE DATABASE [{$dbName}]"); - } else { - $central->execute("CREATE DATABASE IF NOT EXISTS `{$dbName}`"); - } - $dbCreated = true; - } - $this->info("Database [{$dbName}] ready."); - - // 2b. Create the tenant DB user, granted on its database only. - $this->provisionUser($central, $driver, $dbName, $dbUser, $dbPass, $dbHost); - $userCreated = true; - $this->info("User [{$dbUser}] granted on [{$dbName}]."); - - // 3. Run template migrations against the new tenant database. - $template = (string) ($this->option('template') ?: $this->defaultTemplatePath()); - $service = MigrationServiceFactory::fromConfig([ - 'driver' => $driver, - 'host' => $dbHost, - 'port' => $dbPort, - 'database' => $dbName, - 'username' => $dbUser, - 'password' => $dbPass, - 'paths' => [$template], - 'transactional' => true, - ]); - $service->install(); - $result = $service->run(); - $this->success("Applied {$result->appliedCount()} template migration(s)."); - - // 4. Activate. - $central->execute( - 'UPDATE tenants SET status = :s, schema_version = :v WHERE tenant_id = :id', - ['s' => TenantStatus::Active->value, 'v' => 1, 'id' => $tenantId], - ); - } catch (\Throwable $e) { - $this->error("Provisioning failed: {$e->getMessage()}"); - $this->rollbackProvisioning($central, $driver, $dbName, $dbUser, $dbHost, $tenantId, $dbCreated, $userCreated); - return self::FAILURE; - } - - $this->success("Tenant [{$tenantId}] is ACTIVE."); - - // Remember the tenant in var/tenants.json (last created = default) so - // tenant:delete / tenant:host:add work without --tenant/--slug. - // Best-effort convenience — never fail a provisioned tenant over it. - try { - TenantsFile::remember($tenantId, $slug, $name); - $this->info("Recorded as default tenant in " . TenantsFile::path() . '.'); - } catch (\Throwable) { - } - - return self::SUCCESS; - } - - /** - * Create the tenant DB user (idempotent) and grant it full privileges on - * its own database only. The password cannot be parameter-bound in DDL, so - * it is escaped and inlined; identifiers are validated by handle(). - */ - private function provisionUser(DatabasePort $db, string $driver, string $dbName, string $dbUser, string $dbPass, string $dbHost): void - { - if ($driver === 'pgsql') { - $pass = "'" . str_replace("'", "''", $dbPass) . "'"; - $exists = $db->queryOne('SELECT 1 AS present FROM pg_roles WHERE rolname = :u', ['u' => $dbUser]); - if ($exists === null) { - $db->execute("CREATE ROLE \"{$dbUser}\" LOGIN PASSWORD {$pass}"); - } else { - $db->execute("ALTER ROLE \"{$dbUser}\" WITH LOGIN PASSWORD {$pass}"); - } - // GRANT ON DATABASE only covers CONNECT/CREATE/TEMP. Make the role the - // database OWNER so it also owns the public schema (via - // pg_database_owner on PG 15+, and CREATE-to-PUBLIC on older versions) - // — otherwise its template migrations cannot CREATE TABLE. - $db->execute("GRANT ALL PRIVILEGES ON DATABASE \"{$dbName}\" TO \"{$dbUser}\""); - $db->execute("ALTER DATABASE \"{$dbName}\" OWNER TO \"{$dbUser}\""); - - return; - } - - if ($driver === 'sqlsrv') { - $pass = "'" . str_replace("'", "''", $dbPass) . "'"; - $login = str_replace(']', ']]', $dbUser); // escape ] in the bracketed identifier - // Server-level LOGIN (idempotent) — create if absent, else force the - // password so a pre-existing login can't keep a stale credential. - $db->execute( - "IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = '{$dbUser}') " - . "CREATE LOGIN [{$login}] WITH PASSWORD = {$pass}; " - . "ELSE ALTER LOGIN [{$login}] WITH PASSWORD = {$pass};" - ); - // …then a database USER mapped to it, made db_owner of ONLY this database. - $db->execute( - "USE [{$dbName}]; " - . "IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '{$dbUser}') " - . "CREATE USER [{$login}] FOR LOGIN [{$login}]; " - . "ALTER ROLE db_owner ADD MEMBER [{$login}];" - ); - - return; - } - - // MySQL / MariaDB — escape backslash then single quote for the literal. - // The account is pinned to the connecting host (NEVER '%'): for a local - // DB this is the loopback set so it works over both socket ('localhost') - // and TCP ('127.0.0.1'); a remote host is bound to that exact host only. - $pass = "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $dbPass) . "'"; - foreach ($this->grantHosts($dbHost) as $host) { - // CREATE USER IF NOT EXISTS is a no-op — password included — when the - // account already exists, so ALTER USER forces the credential we just - // collected (otherwise a lingering account keeps its stale password - // and the tenant connection below fails "using password: YES"). - $db->execute("CREATE USER IF NOT EXISTS '{$dbUser}'@'{$host}' IDENTIFIED BY {$pass}"); - $db->execute("ALTER USER '{$dbUser}'@'{$host}' IDENTIFIED BY {$pass}"); - $db->execute("GRANT ALL PRIVILEGES ON `{$dbName}`.* TO '{$dbUser}'@'{$host}'"); - } - $db->execute('FLUSH PRIVILEGES'); - } - - /** - * Compensating teardown — undo everything this run created, in reverse order - * (user → database → registry row), so a failed provisioning leaves no trace. - * Each step is isolated so one teardown failure cannot abort the rest. - */ - private function rollbackProvisioning( - DatabasePort $central, - string $driver, - string $dbName, - string $dbUser, - string $dbHost, - string $tenantId, - bool $dbCreated, - bool $userCreated, - ): void { - $this->warning('Rolling back…'); - - if ($userCreated) { - try { - $this->dropDatabaseUser($central, $driver, $dbUser, $dbHost); - $this->info("· dropped user [{$dbUser}]."); - } catch (\Throwable $e) { - $this->error("· could not drop user [{$dbUser}]: {$e->getMessage()}"); - } - } - - if ($dbCreated) { - try { - $this->dropDatabase($central, $driver, $dbName); - $this->info("· dropped database [{$dbName}]."); - } catch (\Throwable $e) { - $this->error("· could not drop database [{$dbName}]: {$e->getMessage()}"); - } - } - - try { - $central->execute('DELETE FROM tenants WHERE tenant_id = :id', ['id' => $tenantId]); - $this->info('· removed registry row.'); - } catch (\Throwable $e) { - $this->error("· could not remove registry row: {$e->getMessage()}"); - } - - $this->warning('Rollback complete — nothing was left provisioned.'); - } - - /** True only when STDIN is a real terminal — guards the prompt wizard. */ - private function isInteractive(): bool - { - return \function_exists('stream_isatty') && @stream_isatty(\STDIN); - } - - /** Conventional default port per driver. */ - private function defaultPort(string $driver): string - { - return match ($driver) { - 'pgsql' => '5432', - 'sqlsrv' => '1433', - default => '3306', - }; - } - - /** Turn an arbitrary string into a DNS/db-safe slug (^[a-z0-9-]+$). */ - private function slugify(string $value): string - { - $slug = preg_replace('/[^a-z0-9]+/', '-', strtolower(trim($value))) ?? ''; - - return trim($slug, '-'); - } - - /** Turn a slug into a safe SQL identifier fragment (^[a-z0-9_]+$). */ - private function identifier(string $value): string - { - return preg_replace('/[^a-z0-9_]+/', '_', strtolower($value)) ?? ''; - } - - private function defaultTemplatePath(): string - { - $custom = env('TENANCY_TEMPLATE_PATH'); - if (is_string($custom) && $custom !== '') { - return $custom; - } - - return dirname(__DIR__, 2) . '/database/tenant-template'; - } -} diff --git a/plugins/Tenancy/Infrastructure/Cli/DeleteTenantCommand.php b/plugins/Tenancy/Infrastructure/Cli/DeleteTenantCommand.php deleted file mode 100644 index 5fc156b..0000000 --- a/plugins/Tenancy/Infrastructure/Cli/DeleteTenantCommand.php +++ /dev/null @@ -1,159 +0,0 @@ - --yes # no confirmation prompt - */ -final class DeleteTenantCommand extends AbstractCommand -{ - use ManagesTenantDatabase; - - public function __construct( - private readonly DatabaseConnectionManagerContract $connections, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this->name = 'tenant:delete'; - $this->description = 'De-provision a tenant: drop its DB user, optionally its database, and the registry row'; - - $this->addOption('tenant', 't', 'Tenant id to delete', acceptsValue: true); - $this->addOption('slug', '', 'Tenant slug to delete', acceptsValue: true); - $this->addOption('drop-database', '', 'Also DROP the tenant database (DESTRUCTIVE — data is lost)'); - $this->addOption('yes', 'y', 'Skip the confirmation prompt'); - } - - protected function handle(): int - { - $id = (string) $this->option('tenant'); - $slug = (string) $this->option('slug'); - - // No id/slug → fall back to the default recorded by tenant:create in - // var/tenants.json (the hint is still validated against the registry). - $fromFile = false; - if ($id === '' && $slug === '') { - $fallback = TenantsFile::defaultTenant(); - if ($fallback === null) { - $this->error('Provide --tenant or --slug (no default tenant recorded in var/tenants.json).'); - return self::FAILURE; - } - $id = $fallback['tenant_id']; - $fromFile = true; - $this->info("Using default tenant [{$fallback['slug']}] from " . TenantsFile::path() . '.'); - } - - $central = $this->connections->default(); - $tenant = $this->resolve($central, $id, $slug); - if ($tenant === null) { - if ($fromFile) { - TenantsFile::forget($id); - $this->error('Recorded default tenant no longer exists in the registry — stale entry dropped from var/tenants.json. Re-run with --tenant or --slug.'); - } else { - $this->error('Tenant not found in the registry.'); - } - return self::FAILURE; - } - - $dropDatabase = $this->hasOption('drop-database'); - - $this->alertInfo('Delete tenant', [ - "tenant : {$tenant->slug} ({$tenant->tenantId})", - "driver : {$tenant->dbDriver}", - "database : {$tenant->dbName} @ {$tenant->dbHost}", - "username : {$tenant->dbUsername}", - 'database will be ' . ($dropDatabase ? 'DROPPED (data lost)' : 'KEPT'), - ]); - - if (!$this->confirmDestruction()) { - $this->warning('Aborted — nothing was deleted.'); - return self::SUCCESS; - } - - $failed = 0; - - try { - $this->dropDatabaseUser($central, $tenant->dbDriver, $tenant->dbUsername, $tenant->dbHost); - $this->info("· dropped user [{$tenant->dbUsername}]."); - } catch (\Throwable $e) { - $failed++; - $this->error("· could not drop user [{$tenant->dbUsername}]: {$e->getMessage()}"); - } - - if ($dropDatabase) { - try { - $this->dropDatabase($central, $tenant->dbDriver, $tenant->dbName); - $this->info("· dropped database [{$tenant->dbName}]."); - } catch (\Throwable $e) { - $failed++; - $this->error("· could not drop database [{$tenant->dbName}]: {$e->getMessage()}"); - } - } - - try { - $central->execute('DELETE FROM tenants WHERE tenant_id = :id', ['id' => $tenant->tenantId]); - $this->info('· removed registry row.'); - TenantsFile::forget($tenant->tenantId); - } catch (\Throwable $e) { - $failed++; - $this->error("· could not remove registry row: {$e->getMessage()}"); - } - - if ($failed > 0) { - $this->warning("Tenant partially deleted — {$failed} step(s) failed (see above)."); - return self::FAILURE; - } - - $this->success("Tenant [{$tenant->slug}] deleted."); - - return self::SUCCESS; - } - - private function resolve(DatabasePort $central, string $id, string $slug): ?Tenant - { - $row = $id !== '' - ? $central->queryOne('SELECT * FROM tenants WHERE tenant_id = :id', ['id' => $id]) - : $central->queryOne('SELECT * FROM tenants WHERE slug = :slug', ['slug' => $slug]); - - return $row === null ? null : Tenant::fromRow($row); - } - - /** - * Require an explicit yes: a prompt when interactive, or the --yes flag in a - * non-interactive context (so this destructive command never runs blind). - */ - private function confirmDestruction(): bool - { - if ($this->hasOption('yes')) { - return true; - } - - if (\function_exists('stream_isatty') && @stream_isatty(\STDIN)) { - return $this->confirm('Delete this tenant now?', false); - } - - $this->error('Refusing to delete non-interactively without --yes.'); - - return false; - } -} diff --git a/plugins/Tenancy/Infrastructure/Cli/MigrateTenantsCommand.php b/plugins/Tenancy/Infrastructure/Cli/MigrateTenantsCommand.php deleted file mode 100644 index 77a7a94..0000000 --- a/plugins/Tenancy/Infrastructure/Cli/MigrateTenantsCommand.php +++ /dev/null @@ -1,221 +0,0 @@ - # one tenant - * hkm tenants:migrate --pretend # print SQL, change nothing - */ -final class MigrateTenantsCommand extends AbstractCommand -{ - public function __construct( - private readonly TenantRegistryContract $registry, - private readonly DatabaseConnectionManagerContract $connections, - private readonly EncryptionPort $crypto, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this->name = 'tenant:migrate'; - $this->description = 'Run tenant template migrations across all active tenant databases'; - - $this->addOption('tenant', 't', 'Migrate only this tenant_id', acceptsValue: true); - $this->addOption('all', 'a', 'Migrate EVERY active tenant in the registry, ignoring var/tenants.json'); - $this->addOption('template', '', 'Override template migrations path', acceptsValue: true); - $this->addOption('pretend', 'p', 'Print SQL instead of executing'); - } - - protected function handle(): int - { - $template = (string) ($this->option('template') ?: $this->defaultTemplatePath()); - $pretend = $this->hasOption('pretend'); - - $tenants = $this->targets(); - if ($tenants === []) { - $this->info('No active tenants to migrate.'); - return self::SUCCESS; - } - - $ok = 0; - $failed = 0; - $central = $this->connections->default(); - - foreach ($tenants as $tenant) { - $label = "{$tenant->slug} ({$tenant->tenantId})"; - try { - $service = MigrationServiceFactory::fromConfig([ - 'driver' => $tenant->dbDriver, - 'host' => $tenant->dbHost, - 'port' => $tenant->dbPort, - 'database' => $tenant->dbName, - 'username' => $tenant->dbUsername, - 'password' => $this->crypto->decryptString($tenant->dbPasswordEnc), - 'paths' => [$template], - 'transactional' => true, - 'pretend' => $pretend, - ]); - - if (!$service->isInstalled()) { - $service->install(); - } - - $pending = $service->pending(); - if ($pending === []) { - $this->info("· {$label}: up to date."); - $ok++; - continue; - } - - if ($pretend) { - [$sql] = $service->captureSql(array_keys($pending)); - $this->info("-- {$label}"); - foreach ($sql as $stmt) { - $this->info($stmt . ';'); - } - $ok++; - continue; - } - - $result = $service->run(); - $version = $tenant->schemaVersion + 1; - $central->execute( - 'UPDATE tenants SET schema_version = :v WHERE tenant_id = :id', - ['v' => $version, 'id' => $tenant->tenantId], - ); - $this->success("✓ {$label}: {$result->appliedCount()} applied (v{$version})."); - $ok++; - } catch (\Throwable $e) { - $failed++; - $this->error("✘ {$label}: {$e->getMessage()}"); - // Continue — never abort the fleet for one tenant. - } - } - - $this->info("Done. {$ok} succeeded, {$failed} failed."); - - return $failed === 0 ? self::SUCCESS : self::FAILURE; - } - - /** - * Which tenants this run touches. - * - * Scope rules, in order: - * --tenant= exactly that tenant (when active). - * --all every active tenant in the registry (fleet-wide). - * default the tenants THIS project recorded in var/tenants.json, - * intersected with the active registry rows. - * - * The default is project-scoped because several projects may share ONE - * central registry. A sibling project's tenant is not ours to migrate: its - * credentials are encrypted with that project's APP_KEY, so we could not - * decrypt them anyway, and its schema is driven by its own tenant-template. - * Without this filter such a tenant surfaces as a spurious decrypt failure - * on every run. - * - * When var/tenants.json is absent or empty (it is disposable, and a - * single-project deployment may never write one) we fall back to the whole - * active fleet — the historical behaviour, so nothing regresses. - * - * @return list - */ - private function targets(): array - { - $one = $this->option('tenant'); - if (\is_string($one) && $one !== '') { - $tenant = $this->registry->find($one); - - return ($tenant !== null && $tenant->status === TenantStatus::Active) ? [$tenant] : []; - } - - $active = $this->registry->listByStatus(TenantStatus::Active->value); - - if ($this->hasOption('all')) { - return $active; - } - - $owned = []; - foreach (TenantsFile::all() as $entry) { - $owned[$entry['tenant_id']] = true; - } - - if ($owned === []) { - return $active; - } - - $scoped = array_values(array_filter( - $active, - static fn (Tenant $t): bool => isset($owned[$t->tenantId]), - )); - - $skipped = \count($active) - \count($scoped); - if ($skipped > 0) { - $this->info( - "· scoped to {$this->tenantsFileLabel()} — skipping {$skipped} tenant(s) " - . 'owned by another project (use --all to include them).' - ); - } - - return $scoped; - } - - /** Short, readable path to var/tenants.json for the scope notice. */ - private function tenantsFileLabel(): string - { - $path = TenantsFile::path(); - $root = Paths::project(); - - return str_starts_with($path, $root) ? ltrim(substr($path, \strlen($root)), '/\\') : $path; - } - - private function defaultTemplatePath(): string - { - // Env override: an absolute path is honoured as-is; a relative one is - // resolved under the active project root. - $custom = env('TENANCY_TEMPLATE_PATH'); - if (is_string($custom) && $custom !== '') { - return $this->isAbsolutePath($custom) ? $custom : Paths::project($custom); - } - - // Project-relative by default: projects//database/tenant-template. - return Paths::project('database/tenant-template'); - } - - /** Unix (/…) or Windows (C:\… / \\…) absolute path. */ - private function isAbsolutePath(string $path): bool - { - return $path[0] === '/' || (bool) preg_match('#^[A-Za-z]:[\\\\/]|^\\\\\\\\#', $path); - } -} diff --git a/plugins/Tenancy/Infrastructure/Cli/RememberTenantCommand.php b/plugins/Tenancy/Infrastructure/Cli/RememberTenantCommand.php deleted file mode 100644 index 7309ead..0000000 --- a/plugins/Tenancy/Infrastructure/Cli/RememberTenantCommand.php +++ /dev/null @@ -1,101 +0,0 @@ - # one tenant, by id - * hkm tenant:remember --all # every registered tenant - * hkm tenant:remember # interactive pick (or auto when only one) - */ -final class RememberTenantCommand extends AbstractCommand -{ - public function __construct( - private readonly DatabaseConnectionManagerContract $connections, - ) { - parent::__construct(); - } - - protected function configure(): void - { - $this->name = 'tenant:remember'; - $this->description = 'Record an existing tenant in var/tenants.json so tenant commands default to it'; - - $this->addOption('tenant', 't', 'Tenant id to remember', acceptsValue: true); - $this->addOption('slug', '', 'Tenant slug to remember', acceptsValue: true); - $this->addOption('all', 'a', 'Remember every registered tenant (last one becomes the default)'); - } - - protected function handle(): int - { - $central = $this->connections->default(); - - if ($this->hasOption('all')) { - $rows = $central->query('SELECT tenant_id, slug, name FROM tenants WHERE deleted_at IS NULL ORDER BY created_at'); - if ($rows === []) { - $this->error('No tenants in the registry — create one with tenant:create first.'); - return self::FAILURE; - } - foreach ($rows as $r) { - TenantsFile::remember((string) $r['tenant_id'], (string) $r['slug'], (string) $r['name']); - $this->info("· remembered [{$r['slug']}] ({$r['tenant_id']})."); - } - $last = end($rows); - $this->success(\count($rows) . ' tenant(s) recorded in ' . TenantsFile::path() . " — default is [{$last['slug']}]."); - return self::SUCCESS; - } - - $id = (string) $this->option('tenant'); - $slug = (string) $this->option('slug'); - - $row = null; - if ($id !== '' || $slug !== '') { - $row = $id !== '' - ? $central->queryOne('SELECT tenant_id, slug, name FROM tenants WHERE tenant_id = :id', ['id' => $id]) - : $central->queryOne('SELECT tenant_id, slug, name FROM tenants WHERE slug = :slug', ['slug' => $slug]); - if ($row === null) { - $this->error('Tenant not found in the registry.'); - return self::FAILURE; - } - } else { - $rows = $central->query('SELECT tenant_id, slug, name FROM tenants WHERE deleted_at IS NULL ORDER BY slug'); - if ($rows === []) { - $this->error('No tenants in the registry — create one with tenant:create first.'); - return self::FAILURE; - } - if (\count($rows) === 1) { - $row = $rows[0]; - } elseif (\function_exists('stream_isatty') && @stream_isatty(\STDIN)) { - $choices = []; - foreach ($rows as $r) { - $choices["{$r['slug']} — {$r['name']} ({$r['tenant_id']})"] = $r; - } - $row = $choices[$this->select('Select the tenant to remember', array_keys($choices))] ?? null; - if ($row === null) { - return self::FAILURE; - } - } else { - $this->error('Several tenants registered — provide --tenant , --slug , or --all.'); - return self::FAILURE; - } - } - - TenantsFile::remember((string) $row['tenant_id'], (string) $row['slug'], (string) $row['name']); - $this->success("Tenant [{$row['slug']}] ({$row['tenant_id']}) recorded as default in " . TenantsFile::path() . '.'); - - return self::SUCCESS; - } -} diff --git a/plugins/Tenancy/Infrastructure/Dns/SystemDnsResolver.php b/plugins/Tenancy/Infrastructure/Dns/SystemDnsResolver.php deleted file mode 100644 index 59f17d0..0000000 --- a/plugins/Tenancy/Infrastructure/Dns/SystemDnsResolver.php +++ /dev/null @@ -1,80 +0,0 @@ -lookup($name, DNS_TXT); - - $values = []; - foreach ($records as $record) { - // PHP exposes the joined string as 'txt' and the raw chunks as 'entries'. - if (isset($record['txt']) && is_string($record['txt'])) { - $values[] = $record['txt']; - } - if (isset($record['entries']) && is_array($record['entries'])) { - $values[] = implode('', $record['entries']); - } - } - - return array_values(array_unique(array_filter($values, static fn (string $v): bool => $v !== ''))); - } - - public function ips(string $hostname): array - { - $ips = []; - - foreach ($this->lookup($hostname, DNS_A) as $record) { - if (isset($record['ip'])) { - $ips[] = (string) $record['ip']; - } - } - foreach ($this->lookup($hostname, DNS_AAAA) as $record) { - if (isset($record['ipv6'])) { - $ips[] = (string) $record['ipv6']; - } - } - - return array_values(array_unique($ips)); - } - - /** - * @return array> - */ - private function lookup(string $name, int $type): array - { - $name = rtrim($name, '.'); - if ($name === '') { - return []; - } - - try { - // Suppress the warning dns_get_record emits on SERVFAIL/timeout — - // a failed lookup is an expected outcome, not an exception path. - $records = @dns_get_record($name, $type); - } catch (\Throwable) { - return []; - } - - return is_array($records) ? $records : []; - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Controllers/InvitationController.php b/plugins/Tenancy/Infrastructure/Http/Controllers/InvitationController.php deleted file mode 100644 index 7a3a44c..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Controllers/InvitationController.php +++ /dev/null @@ -1,53 +0,0 @@ -identity(); - if ($identity->isGuest()) { - return $this->forbidden('Authentication is required.'); - } - - $request = $this->resolveRequest(); - $token = trim((string) $request->input('token')); - if ($token === '') { - return $this->unprocessable(['token' => 'A token is required.']); - } - - $user = $this->users->find($identity->userId); - if ($user === null) { - return $this->forbidden('Unknown user.'); - } - - try { - $tenantId = $this->invitations->accept($token, $identity->userId, $user->email, $request->ip()); - } catch (InvalidInvitationException $e) { - return $this->unprocessable(['token' => $e->getMessage()]); - } - - return $this->ok(['tenantId' => $tenantId]); - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantAdminController.php b/plugins/Tenancy/Infrastructure/Http/Controllers/TenantAdminController.php deleted file mode 100644 index 1f421c5..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantAdminController.php +++ /dev/null @@ -1,118 +0,0 @@ -resolveRequest(). - */ -final class TenantAdminController extends ApiController -{ - /** Permission a caller must hold to manage the tenant fleet. */ - private const ADMIN_PERMISSION = 'tenancy:admin'; - - public function __construct( - private readonly TenantAdminServiceContract $tenants, - ) {} - - /** GET /ajx/admin/tenants — list every tenant in the registry. */ - public function index(): Response - { - if (($guard = $this->guard()) !== null) { - return $guard; - } - - return $this->ok([ - 'data' => array_map(static fn ($t) => $t->toArray(), $this->tenants->list()), - ]); - } - - /** GET /ajx/admin/tenants/{tenantId} — one tenant. */ - public function show(string $tenantId): Response - { - if (($guard = $this->guard()) !== null) { - return $guard; - } - - return $this->okOrNotFound($this->tenants->get($tenantId)?->toArray()); - } - - /** POST /ajx/admin/tenants — provision a new tenant. */ - public function store(): Response - { - if (($guard = $this->guard()) !== null) { - return $guard; - } - - try { - $tenant = $this->tenants->create($this->payload()); - } catch (ValidationException $e) { - return $this->unprocessable($e->errors); - } - - return $this->created($tenant->toArray()); - } - - /** PUT /ajx/admin/tenants/{tenantId} — update name/slug/status. */ - public function update(string $tenantId): Response - { - if (($guard = $this->guard()) !== null) { - return $guard; - } - - try { - $tenant = $this->tenants->update($tenantId, $this->payload()); - } catch (ValidationException $e) { - return $this->unprocessable($e->errors); - } - - return $this->ok($tenant->toArray()); - } - - /** DELETE /ajx/admin/tenants/{tenantId} — de-provision a tenant. */ - public function destroy(string $tenantId): Response - { - if (($guard = $this->guard()) !== null) { - return $guard; - } - - $dropDatabase = $this->resolveRequest()->boolean('drop_database'); - $this->tenants->delete($tenantId, $dropDatabase); - - return $this->noContent(); - } - - /** Deny non-admins. Returns a Response to short-circuit, or null to proceed. */ - private function guard(): ?Response - { - $identity = $this->identity(); - if ($identity->isGuest()) { - return $this->forbidden('Authentication is required.'); - } - if (!$identity->hasPermission(self::ADMIN_PERMISSION) && !$identity->hasRole('platform-admin')) { - return $this->forbidden('Tenant administration requires platform-admin access.'); - } - - return null; - } - - /** @return array */ - private function payload(): array - { - return $this->resolveRequest()->all(); - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantController.php b/plugins/Tenancy/Infrastructure/Http/Controllers/TenantController.php deleted file mode 100644 index 2baa8be..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantController.php +++ /dev/null @@ -1,93 +0,0 @@ -resolveRequest(). The user id always comes from the verified - * Identity, never from the request body — a client cannot act as another user. - */ -final class TenantController extends ApiController -{ - public function __construct( - private readonly MembershipServiceContract $memberships, - private readonly AuthServiceContract $auth, - // User's published tenant-profile reader — fills the `name` claim - // (Identity.fullName). Optional and never-throwing: the token simply - // carries no full name when it is absent or the profile is unreachable. - private readonly ?TenantProfileReaderContract $profiles = null, - private readonly int $tokenTtl = 3600, - ) {} - - /** GET /ajx/me/tenants — the tenant picker for the authenticated user. */ - public function mine(): Response - { - $identity = $this->identity(); - if ($identity->isGuest()) { - return $this->forbidden('Authentication is required.'); - } - - $tenants = $this->memberships->myTenants($identity->userId); - - return $this->ok(['data' => array_map(static fn ($t) => $t->toArray(), $tenants)]); - } - - /** POST /ajx/tenants/{tenantId}/select — re-mint a tenant-scoped token. */ - public function select(string $tenantId): Response - { - $identity = $this->identity(); - if ($identity->isGuest()) { - return $this->forbidden('Authentication is required.'); - } - - try { - $seat = $this->memberships->selectTenant( - $identity->userId, - $tenantId, - $this->resolveRequest()->ip(), - ); - } catch (NotAMemberException) { - return $this->forbidden('You are not an active member of this tenant.'); - } - - $token = $this->auth->issueJwt( - $identity->userId, - [ - 'tnt' => $tenantId, - 'roles' => [$seat->role], - // Full name lives in the TENANT user_profiles table — selection - // is the one place tenant context is known at mint time. - // username/email are filled centrally by AuthService. - 'name' => $this->profiles?->fullName($identity->userId, $tenantId) ?? '', - ], - $this->tokenTtl, - ); - - $selection = new TenantSelection( - token: $token, - tenantId: $tenantId, - role: $seat->role, - expiresIn: $this->tokenTtl, - ); - - return $this->ok($selection->toArray()); - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantHostController.php b/plugins/Tenancy/Infrastructure/Http/Controllers/TenantHostController.php deleted file mode 100644 index 9bb24da..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantHostController.php +++ /dev/null @@ -1,194 +0,0 @@ -request. The tenant id ALWAYS comes from the verified, - * tenant-scoped Identity (`tenantId` claim) — never the request body — so a - * caller can only ever manage hosts for the tenant they are currently scoped to. - * Every route carries the `auth` filter; a host-admin permission can be layered - * on at the route too. - */ -final class TenantHostController extends ApiController -{ - public function __construct( - private readonly TenantHostServiceContract $hosts, - ) {} - - /** GET /ajx/tenant/hosts — list every host for the current tenant. */ - public function index(): Response - { - $tenantId = $this->requireTenant(); - if ($tenantId instanceof Response) { - return $tenantId; - } - - $hosts = $this->hosts->list($tenantId); - - return $this->ok(['data' => array_map(static fn ($h) => $h->toArray(), $hosts)]); - } - - /** POST /ajx/tenant/hosts — register a host; returns DNS challenge to publish. */ - public function store(): Response - { - $tenantId = $this->requireHostManager(); - if ($tenantId instanceof Response) { - return $tenantId; - } - - $hostname = trim((string) $this->request?->input('hostname', '')); - if ($hostname === '') { - return $this->unprocessable(['hostname' => 'A hostname is required.']); - } - - $expectedIp = $this->request?->input('ip_address'); - $expectedIp = is_string($expectedIp) && trim($expectedIp) !== '' ? trim($expectedIp) : null; - - try { - $instructions = $this->hosts->add($tenantId, $hostname, $expectedIp); - } catch (InvalidHostnameException $e) { - return $this->unprocessable(['hostname' => $e->getMessage()]); - } catch (HostQuotaExceededException $e) { - return $this->unprocessable(['hostname' => $e->getMessage()]); - } catch (HostConflictException $e) { - return $this->conflict($e->getMessage()); - } - - return $this->created($instructions->toArray()); - } - - /** GET /ajx/tenant/hosts/{hostId}/instructions — re-show the DNS challenge. */ - public function instructions(string $hostId): Response - { - $tenantId = $this->requireTenant(); - if ($tenantId instanceof Response) { - return $tenantId; - } - - try { - $instructions = $this->hosts->instructions($tenantId, (int) $hostId); - } catch (HostNotFoundException) { - return $this->notFound('Host not found.'); - } - - return $this->ok($instructions->toArray()); - } - - /** POST /ajx/tenant/hosts/{hostId}/verify — scan DNS and (de)verify the host. */ - public function verify(string $hostId): Response - { - $tenantId = $this->requireHostManager(); - if ($tenantId instanceof Response) { - return $tenantId; - } - - try { - $result = $this->hosts->verify($tenantId, (int) $hostId); - } catch (HostNotFoundException) { - return $this->notFound('Host not found.'); - } - - // 200 either way — the body's `verified` flag tells the UI the outcome; - // a failed DNS check is not an HTTP error. - return $this->ok($result->toArray()); - } - - /** POST /ajx/tenant/hosts/{hostId}/primary — make a verified host canonical. */ - public function makePrimary(string $hostId): Response - { - $tenantId = $this->requireHostManager(); - if ($tenantId instanceof Response) { - return $tenantId; - } - - try { - $host = $this->hosts->makePrimary($tenantId, (int) $hostId); - } catch (HostNotFoundException) { - return $this->notFound('Host not found, or not yet verified.'); - } - - return $this->ok($host->toArray()); - } - - /** DELETE /ajx/tenant/hosts/{hostId} — stop routing a host. */ - public function destroy(string $hostId): Response - { - $tenantId = $this->requireHostManager(); - if ($tenantId instanceof Response) { - return $tenantId; - } - - try { - $this->hosts->remove($tenantId, (int) $hostId); - } catch (HostNotFoundException) { - return $this->notFound('Host not found.'); - } - - return $this->noContent(); - } - - /** - * The tenant the caller is currently scoped to, or a 403 Response when the - * request is unauthenticated / not yet tenant-scoped (pick a tenant first). - */ - private function requireTenant(): string|Response - { - $identity = $this->identity(); - if ($identity->isGuest()) { - return $this->forbidden('Authentication is required.'); - } - if ($identity->tenantId === '') { - return $this->forbidden('Select a tenant before managing its hosts.'); - } - - return $identity->tenantId; - } - - /** - * Like {@see requireTenant()} but also requires the caller be allowed to - * MANAGE hosts — a privileged action (it changes which domains route to the - * tenant). Gate: the `tenant.hosts.manage` permission OR an owner/admin role - * on the active tenant-scoped Identity. Reading the list stays open to any - * member. - */ - private function requireHostManager(): string|Response - { - $tenantId = $this->requireTenant(); - if ($tenantId instanceof Response) { - return $tenantId; - } - - $identity = $this->identity(); - $allowed = $identity->hasPermission('tenant.hosts.manage') - || $identity->hasRole('owner') - || $identity->hasRole('admin'); - - if (!$allowed) { - return $this->forbidden('You are not allowed to manage this tenant\'s hosts.'); - } - - return $tenantId; - } - - /** 409 Conflict in the standard error envelope. */ - private function conflict(string $message): Response - { - return Response::json( - ['error' => ['code' => 'host.conflict', 'message' => $message]], - 409, - ); - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantPageController.php b/plugins/Tenancy/Infrastructure/Http/Controllers/TenantPageController.php deleted file mode 100644 index 58bf032..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Controllers/TenantPageController.php +++ /dev/null @@ -1,82 +0,0 @@ - plus - * noindex,nofollow (admin consoles must never enter a search index), with no - * OG/graph cost. The helper no-ops ('') on Pageflow XHR navigations. - */ -final class TenantPageController -{ - use InteractsWithGraphSeo; - - public function __construct( - private readonly PageflowResponder $pageflow, - ) {} - - /** GET /tenants — the tenant picker for the authenticated user. */ - public function index(Request $request): Response - { - return $this->pageflow->render($request, 'Tenant/Index', 'admin', [ - 'seoHead' => $this->seoPrivate('Choose a workspace', request: $request), - ]); - } - - /** GET /tenants/manage — the platform-admin tenant fleet (CRUD). */ - public function manage(Request $request): Response - { - return $this->pageflow->render($request, 'Tenant/Manage', 'admin', [ - 'seoHead' => $this->seoPrivate('Tenant fleet', request: $request), - ]); - } - - /** GET /tenants/create — the new-tenant provisioning form. */ - public function create(Request $request): Response - { - return $this->pageflow->render($request, 'Tenant/Create', 'admin', [ - 'seoHead' => $this->seoPrivate('New tenant', request: $request), - ]); - } - - /** GET /tenants/{tenantId}/edit — edit a tenant's metadata. */ - public function edit(Request $request, string $tenantId): Response - { - return $this->pageflow->render($request, 'Tenant/Edit', 'admin', [ - 'tenantId' => $tenantId, - 'seoHead' => $this->seoPrivate('Edit tenant', request: $request), - ]); - } - - /** GET /tenant/hosts — manage the current tenant's custom domains. */ - public function hosts(Request $request): Response - { - return $this->pageflow->render($request, 'Tenant/Hosts', 'admin', [ - 'seoHead' => $this->seoPrivate('Custom domains', request: $request), - ]); - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Identification/ClaimTenantIdentifier.php b/plugins/Tenancy/Infrastructure/Http/Identification/ClaimTenantIdentifier.php deleted file mode 100644 index ca148a4..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Identification/ClaimTenantIdentifier.php +++ /dev/null @@ -1,23 +0,0 @@ -identity()?->tenantId ?? ''; - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Identification/DomainTenantIdentifier.php b/plugins/Tenancy/Infrastructure/Http/Identification/DomainTenantIdentifier.php deleted file mode 100644 index 7356a8e..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Identification/DomainTenantIdentifier.php +++ /dev/null @@ -1,109 +0,0 @@ - "acme" - * globex.shop.example -> "globex" - * shop.localhost -> "" (apex = central / no tenant) - * - * This stage only PRODUCES the candidate id from the host; whether that id is a - * real, routable tenant is decided downstream by the TenantRegistry (an unknown - * id throws UnknownTenantException -> 404). No auth is involved, so domain mode - * works for anonymous storefront traffic. - * - * Base domains are configured via TENANCY_BASE_DOMAINS (comma-separated). With - * no base domain configured, the left-most label of a multi-label host is used. - * - * RESERVED sub-domains (www, api, admin, …) are NOT tenants: they map to the - * central connection (return '') so infrastructure/marketing hosts keep working - * instead of 404-ing as an unknown tenant. Configure via TENANCY_RESERVED_SUBDOMAINS. - */ -final class DomainTenantIdentifier implements TenantIdentifier -{ - /** @var string[] lower-cased base domains, longest first */ - private array $baseDomains; - - /** @var array reserved labels that resolve to central, not a tenant */ - private array $reserved; - - /** - * @param string[] $baseDomains e.g. ['shop.localhost', 'shop.example'] - * @param string[] $reserved sub-domain labels that are never tenants (www, api, …) - */ - public function __construct(array $baseDomains, array $reserved = []) - { - $normalised = array_map( - static fn (string $d): string => strtolower(trim($d, '. ')), - $baseDomains, - ); - - // Longest base domain first so 'a.shop.localhost' strips '.shop.localhost' - // before a shorter, accidentally-matching suffix. - usort($normalised, static fn (string $a, string $b): int => strlen($b) <=> strlen($a)); - - $this->baseDomains = array_values(array_filter($normalised)); - - $this->reserved = []; - foreach ($reserved as $label) { - $label = strtolower(trim($label)); - if ($label !== '') { - $this->reserved[$label] = true; - } - } - } - - public function identify(Request $request): string - { - $host = $this->normaliseHost($request->host()); - if ($host === '') { - return ''; - } - - $label = $this->candidateLabel($host); - - // Reserved infra/marketing sub-domains are central, never a tenant. - if ($label === null || isset($this->reserved[$label])) { - return ''; - } - - return $label; - } - - private function candidateLabel(string $host): ?string - { - foreach ($this->baseDomains as $base) { - if ($host === $base) { - return null; // apex == central, no tenant - } - - if (str_ends_with($host, '.' . $base)) { - $sub = substr($host, 0, -\strlen('.' . $base)); - - return explode('.', $sub)[0] ?: null; - } - } - - // No base domain matched — treat the left-most label as the candidate, - // but only when the host actually has more than one label. - $first = explode('.', $host)[0]; - - return $first !== $host ? $first : null; - } - - private function normaliseHost(string $host): string - { - $host = strtolower(trim($host)); - $host = preg_replace('/:\d+$/', '', $host) ?? $host; // strip port - $host = trim($host, '.'); // strip trailing dot - - return $host; - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Identification/HostTenantIdentifier.php b/plugins/Tenancy/Infrastructure/Http/Identification/HostTenantIdentifier.php deleted file mode 100644 index 3d9f18e..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Identification/HostTenantIdentifier.php +++ /dev/null @@ -1,48 +0,0 @@ - tenant that registered & verified 'acme.example.com' - * shop.acme.io -> tenant that registered & verified 'shop.acme.io' - * unknown.host -> '' (no verified host — TenantContextStage answers - * 404: every host must be assigned to a tenant) - * - * Unlike {@see DomainTenantIdentifier}, this does NOT derive the id from the - * host string — it maps the FULL hostname to a tenant_id through a verified row, - * so tenants can bring their own apex/sub domains (not just labels under one - * configured base domain). No auth is involved: it works for anonymous traffic. - * - * The registry already restricts matches to status = verified. - */ -final class HostTenantIdentifier implements TenantIdentifier -{ - public function __construct( - private readonly TenantHostRegistryContract $hosts, - ) {} - - public function identify(Request $request): string - { - $host = $this->normaliseHost($request->host()); - if ($host === '') { - return ''; - } - - return $this->hosts->tenantForHost($host) ?? ''; - } - - private function normaliseHost(string $host): string - { - $host = strtolower(trim($host)); - $host = preg_replace('/:\d+$/', '', $host) ?? $host; // strip port - return trim($host, '.'); // strip trailing dot - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Identification/TenantIdentifier.php b/plugins/Tenancy/Infrastructure/Http/Identification/TenantIdentifier.php deleted file mode 100644 index 087ca5c..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Identification/TenantIdentifier.php +++ /dev/null @@ -1,34 +0,0 @@ -attribute('tenant') ?? ''); - - if ($tenant === '') { - return Response::json([ - 'error' => [ - 'code' => 'tenant.required', - 'message' => 'No active tenant. Select a tenant before accessing this resource.', - ], - ], 409); - } - - return $next($request); - } -} diff --git a/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php b/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php deleted file mode 100644 index a163581..0000000 --- a/plugins/Tenancy/Infrastructure/Http/Stages/TenantContextStage.php +++ /dev/null @@ -1,283 +0,0 @@ - 404. - * Every host must be assigned to a tenant; a request that cannot be scoped - * is never served the central DatabasePort. Control-plane code that needs - * central pins it explicitly (ConnectionManager default), not via this stage. - * - Tenant present -> rebind, or fail closed with a clean status. There is no - * silent fallback to another tenant or to central. - * - * This stage is purely connection routing; tenant IDENTIFICATION is the - * identifier's job and tenant existence is the registry's. - */ -final class TenantContextStage implements HttpStageContract -{ - /** Encrypted, user-bound cookie remembering the active tenant (a HINT, never authority). */ - private const COOKIE = 'hkm_tnat_v01'; - - /** - * The collaborators are request-scoped (bound in the module's ModuleContainer - * by Provider::register), but this stage is an app-lifetime after.load hook - * resolved once from the CoreContainer. So they are OPTIONAL here and lazily - * resolved from the per-request container when not explicitly injected - * (explicit injection is used by tests / a CoreContainer binding). - */ - public function __construct( - private readonly ?TenantConnectionResolverContract $resolver = null, - private readonly ?TenantIdentifier $identifier = null, - ) { - } - - /** - * Paths that are served WITHOUT a tenant scope (health/infra endpoints). - * Configured via TENANCY_EXEMPT (comma-separated; trailing '*' = prefix - * match), defaulting to '/ping'. Memoised once — this stage is an - * app-lifetime hook, not request-scoped, so the static cache is safe. - */ - private function isExempt(string $path): bool - { - static $paths = null; - if ($paths === null) { - $raw = (string) (env('TENANCY_EXEMPT', '/ping') ?? '/ping'); - $paths = array_values(array_filter( - array_map('trim', explode(',', $raw)), - static fn (string $p): bool => $p !== '', - )); - } - - foreach ($paths as $p) { - if (str_ends_with($p, '*')) { - if (str_starts_with($path, rtrim($p, '*'))) { - return true; - } - } elseif ($path === $p) { - return true; - } - } - - return false; - } - - public function handle(Request $request, callable $next): Response - { - // Infrastructure/health endpoints (TENANCY_EXEMPT, default '/ping') carry - // no tenant scope — skip identification AND the per-request DB rebind - // entirely. This stage is an always-on essential hook, so without this a - // bare /ping would still pay a host->tenant DB lookup it never needs. - if ($this->isExempt($request->path())) { - return $next($request); - } - - $container = $request->container(); - - // This stage is an always-on after.load hook, but its collaborators are - // bound only when Tenancy is in the request's dependency graph. When the - // route never pulled Tenancy in, TenantIdentifier is unbound — make() - // would THROW (EntryNotFoundException), not return null. Probe with has() - // so a non-tenant request cleanly no-ops instead of erroring. - $identifier = $this->identifier - ?? ($container?->has(TenantIdentifier::class) ? $container->make(TenantIdentifier::class) : null); - - // No identifier bound means Tenancy is not in this request's dependency - // graph — fail loudly instead of silently skipping tenant resolution. - // (Register Tenancy as an essential module so it binds on every request.) - if ($identifier === null) { - throw new \RuntimeException( - 'TenantContextStage: no TenantIdentifier is bound for this request. ' - . 'Ensure the Tenancy module is loaded — register it as an essential module.' - ); - } - - $jar = $container->has(CookieJar::class) ? $container->make(CookieJar::class) : null; - $userId = $request->identity()?->userId ?? ''; - - - - $fromCookie = false; - $tenantId = $this->rememberedTenant($jar, $request, $userId); - $fromCookie = $tenantId !== ''; - - // The remembered selection (encrypted, principal-bound cookie) is tried - // first; only when there is no valid hint does the identifier run - // (JWT `tnt` claim / Host, per TENANCY_MODE). An identifier may throw - // UnknownTenantException to fail closed on a host it refuses to serve. - try { - if ($tenantId === '') { - $tenantId = $identifier->identify($request); - } - } catch (UnknownTenantException) { - return Response::notFound('Tenant not found.'); - } - - // EVERY request must resolve to a tenant — there is NO unscoped - // passthrough to the central DatabasePort. A host that is not assigned - // to a tenant (and a claim-mode request without a tenant claim/cookie) - // fails closed with a 404 here, so an unknown website pointed at this - // server is never served anything. Control-plane repositories that need - // the central connection pin it explicitly via the ConnectionManager - // default — they do not depend on this stage skipping the rebind. - if ($tenantId === '') { - return Response::notFound('Tenant not found.'); - } - - $resolver = $this->resolver ?? $container->make(TenantConnectionResolverContract::class); - - - try { - $db = $resolver->for($tenantId); - } catch (UnknownTenantException) { - // A stale hint can point at a deleted tenant — drop it, don't trap the user. - if ($fromCookie && $jar !== null) { - $jar->forget(self::COOKIE); - } - return Response::notFound('Tenant not found.'); - } catch (TenantUnavailableException $e) { - return $this->unavailable($e); - } - - // Override the Database plugin's binding for THIS request only. - $container->instance(DatabasePort::class, $db); - - - // Expose the resolved tenant to controllers without re-identifying it. - $request = $request->withAttribute('tenant', $tenantId); - - // Expose the resolved tenant id (a scalar) into the container too. Use a - // closure bind, not instance(): the kernel's ModuleContainer::instance() - // requires an OBJECT, so binding a bare string there throws a TypeError. - $container->bind('tenant.current', static fn(): string => $tenantId); - - // Remember the active tenant for next time — encrypted + bound to this - // user so it cannot be replayed across users. Still a hint: every request - // re-resolves through the registry/breaker above. - if ($jar !== null) { - $this->rememberTenant($jar, $tenantId, $userId); - } - - try { - $response = $next($request); - } catch (\Throwable $e) { - // Repositories translate \PDOException → Plugins\Database - // ConnectionException (and may wrap it again), so the raw vendor - // exception never reaches this stage. Walk the chain and only feed - // the breaker on genuine CONNECTIVITY faults — a bad query or a - // domain error must not trip a healthy tenant's breaker. Then - // re-throw the original so the error pipeline handles it. - if ($resolver instanceof TenantConnectionResolver && self::isConnectivityFault($e)) { - $resolver->recordFailure($tenantId, $e); - } - throw $e; - } - - if ($resolver instanceof TenantConnectionResolver) { - $resolver->recordSuccess($tenantId); - } - - return $response; - } - - /** - * True when the throwable (or anything in its `previous` chain) is a Database - * ConnectionException whose operation indicates the connection itself failed - * — connect, lost connection, or pool acquisition — as opposed to a query - * that ran fine against a healthy connection. - */ - private static function isConnectivityFault(\Throwable $e): bool - { - for ($cur = $e; $cur !== null; $cur = $cur->getPrevious()) { - if ( - $cur instanceof ConnectionException - && \in_array($cur->operation, ['connect', 'connection_lost', 'pool_acquire'], true) - ) { - return true; - } - } - - return false; - } - - /** - * Read the remembered tenant from the encrypted cookie. Returns '' unless the - * cookie decrypts cleanly AND was minted for THIS principal — a cookie issued - * for another account can never be replayed, while a guest-minted cookie - * ('u' = '') still works for guests so public pages keep their selection. - */ - private function rememberedTenant(?CookieJar $jar, Request $request, string $userId): string - { - if ($jar === null) { - return ''; - } - - $raw = $jar->read($request, self::COOKIE); // decrypted; null if absent/tampered - - if ($raw === null) { - return ''; - } - - $data = json_decode($raw, true); - - // Enforce the principal binding: the hint is only honoured by whoever it - // was minted for. A guest-minted hint ('u' = '') keeps working for - // guests — public pages don't require login — while a hint minted for - // one user is never replayed onto another user (or onto a guest after - // logout). Log-in flips the principal, so a fresh hint is re-minted. - $mintedFor = is_string($data['u'] ?? null) ? $data['u'] : ''; - if (!is_string($data['t'] ?? null) || $mintedFor !== $userId) { - return ''; - } - - return $data['t']; - } - - /** Queue the encrypted, user-bound tenant hint (flushed by QueuedCookiesStage). */ - private function rememberTenant(CookieJar $jar, string $tenantId, string $userId): void - { - if ($jar->hasQueued(self::COOKIE)) { - return; // already written this request - } - - $jar->queue( - self::COOKIE, - json_encode(['t' => $tenantId, 'u' => $userId]), - 60 * 60 * 24 * 30, // 30 days - ); - } - - private function unavailable(TenantUnavailableException $e): Response - { - return match ($e->statusCode) { - 403 => Response::forbidden($e->getMessage()), - 410 => Response::json(['error' => ['code' => $e->reason, 'message' => $e->getMessage()]], 410), - default => Response::json(['error' => ['code' => $e->reason, 'message' => $e->getMessage()]], 503) - ->withHeader('Retry-After', '30'), - }; - } -} diff --git a/plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php b/plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php deleted file mode 100644 index 18ee772..0000000 --- a/plugins/Tenancy/Infrastructure/Persistence/AuditLogRepository.php +++ /dev/null @@ -1,123 +0,0 @@ -page('', [], $limit, $beforeId); - } - - public function forTenant(string $tenantId, int $limit = 50, ?int $beforeId = null): array - { - return $this->page('tenant_id = :tenant_id', ['tenant_id' => $tenantId], $limit, $beforeId); - } - - public function forUser(string $userId, int $limit = 50, ?int $beforeId = null): array - { - return $this->page('user_id = :user_id', ['user_id' => $userId], $limit, $beforeId); - } - - public function byAction(string $action, int $limit = 50, ?int $beforeId = null): array - { - return $this->page('action = :action', ['action' => $action], $limit, $beforeId); - } - - public function find(string $eventId): ?AuditEntry - { - try { - $row = $this->central->queryOne( - self::SELECT . ' WHERE event_id = :event_id LIMIT 1', - ['event_id' => $eventId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to load audit entry.', layer: 'repository.tenancy', previous: $e); - } - - return $row === null ? null : AuditEntry::fromRow($row); - } - - public function countForTenant(string $tenantId): int - { - try { - $row = $this->central->queryOne( - 'SELECT COUNT(*) AS c FROM audit_log WHERE tenant_id = :tenant_id', - ['tenant_id' => $tenantId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to count audit entries.', layer: 'repository.tenancy', previous: $e); - } - - return (int) ($row['c'] ?? 0); - } - - public function purgeOlderThan(\DateTimeImmutable $cutoff): int - { - try { - return $this->central->execute( - 'DELETE FROM audit_log WHERE occurred_at < :cutoff', - ['cutoff' => $cutoff->format('Y-m-d H:i:s')], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to purge audit entries.', layer: 'repository.tenancy', previous: $e); - } - } - - /** - * Run a keyset-paginated listing with an optional WHERE filter. - * - * @param array $params - * @return list - */ - private function page(string $where, array $params, int $limit, ?int $beforeId): array - { - $limit = max(1, min(self::MAX_LIMIT, $limit)); - $clauses = $where !== '' ? [$where] : []; - - if ($beforeId !== null) { - $clauses[] = 'id < ' . (int) $beforeId; // validated int — keyset cursor - } - - $sql = self::SELECT - . ($clauses !== [] ? ' WHERE ' . implode(' AND ', $clauses) : '') - . ' ORDER BY id DESC LIMIT ' . $limit; - - try { - $rows = $this->central->query($sql, $params); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to list audit entries.', layer: 'repository.tenancy', previous: $e); - } - - return array_map(static fn (array $r): AuditEntry => AuditEntry::fromRow($r), $rows); - } -} diff --git a/plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php b/plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php deleted file mode 100644 index 70b4645..0000000 --- a/plugins/Tenancy/Infrastructure/Persistence/AuditTrail.php +++ /dev/null @@ -1,60 +0,0 @@ -central->execute( - 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) - VALUES (:eid, :uid, :tid, :action, :ip, :meta, :ts)', - [ - 'eid' => Token::ulid(), - 'uid' => $userId, - 'tid' => $tenantId, - 'action' => $action, - 'ip' => $ip, - 'meta' => $meta === [] ? null : json_encode($meta, JSON_UNESCAPED_SLASHES), - 'ts' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to write audit entry.', - layer: 'repository.tenancy', - context: ['action' => $action], - previous: $e, - ); - } - } -} diff --git a/plugins/Tenancy/Infrastructure/Persistence/InvitationRepository.php b/plugins/Tenancy/Infrastructure/Persistence/InvitationRepository.php deleted file mode 100644 index 46f04dc..0000000 --- a/plugins/Tenancy/Infrastructure/Persistence/InvitationRepository.php +++ /dev/null @@ -1,110 +0,0 @@ -central->execute( - 'INSERT INTO tenant_invitations - (invite_id, tenant_id, email, role, token_hash, invited_by, status, expires_at, created_at, updated_at) - VALUES (:iid, :tid, :email, :role, :hash, :by, :status, :exp, :now, :now)', - [ - 'iid' => $inviteId, - 'tid' => $tenantId, - 'email' => $email, - 'role' => $role, - 'hash' => $tokenHash, - 'by' => $invitedBy, - 'status' => InvitationStatus::Pending->value, - 'exp' => $expiresAt->format('Y-m-d H:i:s'), - 'now' => self::now(), - ], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to create invitation.', layer: 'repository.tenancy', previous: $e); - } - } - - public function findByTokenHash(string $tokenHash): ?Invitation - { - try { - $row = $this->central->queryOne( - 'SELECT invite_id, tenant_id, email, role, status, expires_at, invited_by - FROM tenant_invitations WHERE token_hash = :hash LIMIT 1', - ['hash' => $tokenHash], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to load invitation.', layer: 'repository.tenancy', previous: $e); - } - - return $row === null ? null : Invitation::fromRow($row); - } - - public function pendingExists(string $tenantId, string $email): bool - { - try { - $row = $this->central->queryOne( - 'SELECT 1 AS hit FROM tenant_invitations - WHERE tenant_id = :tid AND email = :email AND status = :status LIMIT 1', - ['tid' => $tenantId, 'email' => $email, 'status' => InvitationStatus::Pending->value], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to check invitation.', layer: 'repository.tenancy', previous: $e); - } - - return $row !== null; - } - - public function markAccepted(string $inviteId): void - { - $this->setStatus($inviteId, InvitationStatus::Accepted, acceptedAt: true); - } - - public function markRevoked(string $inviteId): void - { - $this->setStatus($inviteId, InvitationStatus::Revoked); - } - - private function setStatus(string $inviteId, InvitationStatus $status, bool $acceptedAt = false): void - { - $sql = 'UPDATE tenant_invitations SET status = :status, updated_at = :now' - . ($acceptedAt ? ', accepted_at = :now' : '') - . ' WHERE invite_id = :iid'; - try { - $this->central->execute($sql, ['status' => $status->value, 'now' => self::now(), 'iid' => $inviteId]); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to update invitation.', layer: 'repository.tenancy', previous: $e); - } - } - - private static function now(): string - { - return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - } -} diff --git a/plugins/Tenancy/Infrastructure/Persistence/MembershipRepository.php b/plugins/Tenancy/Infrastructure/Persistence/MembershipRepository.php deleted file mode 100644 index f91a585..0000000 --- a/plugins/Tenancy/Infrastructure/Persistence/MembershipRepository.php +++ /dev/null @@ -1,93 +0,0 @@ -central->query( - self::SELECT . ' - WHERE ut.user_id = :uid - AND ut.status = 1 -- active seat - AND t.status = 1 -- active tenant - ORDER BY t.name ASC', - ['uid' => $userId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to list memberships.', layer: 'repository.tenancy', previous: $e); - } - - return array_map(static fn (array $r): Membership => Membership::fromRow($r), $rows); - } - - public function find(string $userId, string $tenantId): ?Membership - { - try { - $row = $this->central->queryOne( - self::SELECT . ' - WHERE ut.user_id = :uid AND ut.tenant_id = :tid - LIMIT 1', - ['uid' => $userId, 'tid' => $tenantId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to load membership.', layer: 'repository.tenancy', previous: $e); - } - - return $row === null ? null : Membership::fromRow($row); - } - - public function upsertActive(string $userId, string $tenantId, string $role): void - { - $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - // Single atomic, driver-portable upsert (no UPDATE-then-INSERT race). - // joined_at is inserted but NOT in the update set, so an existing - // seat keeps its original join time while role/status are refreshed. - $this->central->upsert( - 'user_tenants', - [ - 'user_id' => $userId, - 'tenant_id' => $tenantId, - 'role' => $role, - 'status' => MembershipStatus::Active->value, - 'joined_at' => $now, - 'created_at' => $now, - 'updated_at' => $now, - ], - conflictColumns: ['user_id', 'tenant_id'], - updateColumns: ['role', 'status', 'updated_at'], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to upsert membership.', layer: 'repository.tenancy', previous: $e); - } - } -} diff --git a/plugins/Tenancy/Infrastructure/Persistence/TenantAdminRepository.php b/plugins/Tenancy/Infrastructure/Persistence/TenantAdminRepository.php deleted file mode 100644 index dfa2473..0000000 --- a/plugins/Tenancy/Infrastructure/Persistence/TenantAdminRepository.php +++ /dev/null @@ -1,130 +0,0 @@ -db->query('SELECT * FROM tenants ORDER BY status ASC, name ASC'); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to list tenants', layer: 'repository.tenant_admin', previous: $e); - } - - return array_map(static fn (array $row): Tenant => Tenant::fromRow($row), $rows); - } - - public function find(string $tenantId): ?Tenant - { - try { - $row = $this->db->queryOne('SELECT * FROM tenants WHERE tenant_id = :id', ['id' => $tenantId]); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to load tenant', layer: 'repository.tenant_admin', context: ['id' => $tenantId], previous: $e); - } - - return $row === null ? null : Tenant::fromRow($row); - } - - public function slugExists(string $slug, ?string $exceptId = null): bool - { - try { - $row = $exceptId === null - ? $this->db->queryOne('SELECT 1 AS p FROM tenants WHERE slug = :s', ['s' => $slug]) - : $this->db->queryOne('SELECT 1 AS p FROM tenants WHERE slug = :s AND tenant_id <> :id', ['s' => $slug, 'id' => $exceptId]); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to check slug', layer: 'repository.tenant_admin', context: ['slug' => $slug], previous: $e); - } - - return $row !== null; - } - - public function insert(Tenant $tenant): void - { - try { - $this->db->execute( - 'INSERT INTO tenants - (tenant_id, name, slug, db_driver, db_host, db_port, db_name, - db_username, db_password_enc, status, schema_version) - VALUES (:id, :name, :slug, :driver, :host, :port, :db, :user, :pass, :status, :version)', - [ - 'id' => $tenant->tenantId, 'name' => $tenant->name, 'slug' => $tenant->slug, - 'driver' => $tenant->dbDriver, 'host' => $tenant->dbHost, 'port' => $tenant->dbPort, - 'db' => $tenant->dbName, 'user' => $tenant->dbUsername, - 'pass' => $tenant->dbPasswordEnc, - 'status' => $tenant->status->value, 'version' => $tenant->schemaVersion, - ], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to insert tenant', layer: 'repository.tenant_admin', context: ['id' => $tenant->tenantId], previous: $e); - } - } - - public function markActive(string $tenantId, int $schemaVersion): void - { - try { - $this->db->execute( - 'UPDATE tenants SET status = :s, schema_version = :v WHERE tenant_id = :id', - ['s' => \Plugins\Tenancy\Domain\ValueObjects\TenantStatus::Active->value, 'v' => $schemaVersion, 'id' => $tenantId], - ); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to activate tenant', layer: 'repository.tenant_admin', context: ['id' => $tenantId], previous: $e); - } - } - - public function updateMeta(string $tenantId, ?string $name, ?string $slug, ?int $status): void - { - $sets = []; - $params = ['id' => $tenantId]; - - if ($name !== null) { - $sets[] = 'name = :name'; - $params['name'] = $name; - } - if ($slug !== null) { - $sets[] = 'slug = :slug'; - $params['slug'] = $slug; - } - if ($status !== null) { - $sets[] = 'status = :status'; - $params['status'] = $status; - } - if ($sets === []) { - return; - } - - try { - $this->db->execute('UPDATE tenants SET ' . implode(', ', $sets) . ' WHERE tenant_id = :id', $params); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to update tenant', layer: 'repository.tenant_admin', context: ['id' => $tenantId], previous: $e); - } - } - - public function delete(string $tenantId): void - { - try { - $this->db->execute('DELETE FROM tenants WHERE tenant_id = :id', ['id' => $tenantId]); - } catch (\PDOException $e) { - throw new RepositoryException('Failed to delete tenant', layer: 'repository.tenant_admin', context: ['id' => $tenantId], previous: $e); - } - } -} diff --git a/plugins/Tenancy/Infrastructure/Persistence/TenantHostRegistry.php b/plugins/Tenancy/Infrastructure/Persistence/TenantHostRegistry.php deleted file mode 100644 index 608e6c1..0000000 --- a/plugins/Tenancy/Infrastructure/Persistence/TenantHostRegistry.php +++ /dev/null @@ -1,86 +0,0 @@ -normalise($hostname); - if ($hostname === '') { - return null; - } - - $key = $this->key($hostname); - - $cached = $this->cache->get($key); - if ($cached === self::MISS) { - return null; - } - if (is_string($cached) && $cached !== '') { - return $cached; - } - - $row = $this->central->queryOne( - 'SELECT tenant_id - FROM tenant_hosts - WHERE hostname = :host - AND status = :status - AND deleted_at IS NULL', - ['host' => $hostname, 'status' => self::STATUS_VERIFIED], - ); - - if ($row === null) { - $this->cache->set($key, self::MISS, $this->ttl); - return null; - } - - $tenantId = (string) $row['tenant_id']; - $this->cache->set($key, $tenantId, $this->ttl); - - return $tenantId; - } - - public function forget(string $hostname): void - { - $this->cache->delete($this->key($this->normalise($hostname))); - } - - private function normalise(string $hostname): string - { - $hostname = strtolower(trim($hostname)); - $hostname = preg_replace('/:\d+$/', '', $hostname) ?? $hostname; // strip port - return trim($hostname, '.'); // strip trailing dot - } - - private function key(string $hostname): string - { - return 'tenancy:host:' . $hostname; - } -} diff --git a/plugins/Tenancy/Infrastructure/Persistence/TenantHostRepository.php b/plugins/Tenancy/Infrastructure/Persistence/TenantHostRepository.php deleted file mode 100644 index c1a5f46..0000000 --- a/plugins/Tenancy/Infrastructure/Persistence/TenantHostRepository.php +++ /dev/null @@ -1,187 +0,0 @@ -central->query( - 'SELECT ' . self::COLUMNS . ' - FROM tenant_hosts - WHERE tenant_id = :tid AND deleted_at IS NULL - ORDER BY is_primary DESC, hostname ASC', - ['tid' => $tenantId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to list tenant hosts.', layer: 'repository.tenancy', previous: $e); - } - - return array_map(static fn (array $r): TenantHost => TenantHost::fromRow($r), $rows); - } - - public function find(string $tenantId, int $hostId): ?TenantHost - { - try { - $row = $this->central->queryOne( - 'SELECT ' . self::COLUMNS . ' - FROM tenant_hosts - WHERE tenant_id = :tid AND host_id = :hid AND deleted_at IS NULL - LIMIT 1', - ['tid' => $tenantId, 'hid' => $hostId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to load tenant host.', layer: 'repository.tenancy', previous: $e); - } - - return $row === null ? null : TenantHost::fromRow($row); - } - - public function hostnameTaken(string $hostname): bool - { - try { - $row = $this->central->queryOne( - 'SELECT host_id FROM tenant_hosts - WHERE hostname = :host AND deleted_at IS NULL - LIMIT 1', - ['host' => $hostname], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to check hostname.', layer: 'repository.tenancy', previous: $e); - } - - return $row !== null; - } - - public function insert(string $tenantId, string $hostname, ?string $ipAddress, string $verificationToken): int - { - $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - $this->central->execute( - 'INSERT INTO tenant_hosts - (tenant_id, hostname, ip_address, status, verification_token, is_primary, created_at, updated_at) - VALUES (:tid, :host, :ip, 0, :token, 0, :created, :updated)', - [ - 'tid' => $tenantId, - 'host' => $hostname, - 'ip' => $ipAddress, - 'token' => $verificationToken, - 'created' => $now, - 'updated' => $now, - ], - ); - - return (int) $this->central->lastInsertId(); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to register tenant host.', layer: 'repository.tenancy', previous: $e); - } - } - - public function markStatus(string $tenantId, int $hostId, int $status, ?string $verifiedAt): void - { - $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - $this->central->execute( - 'UPDATE tenant_hosts - SET status = :status, verified_at = :verified, updated_at = :now - WHERE tenant_id = :tid AND host_id = :hid AND deleted_at IS NULL', - ['status' => $status, 'verified' => $verifiedAt, 'now' => $now, 'tid' => $tenantId, 'hid' => $hostId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to update host status.', layer: 'repository.tenancy', previous: $e); - } - } - - public function setPrimary(string $tenantId, int $hostId): void - { - $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - $this->central->beginTransaction(); - - // Demote every host of the tenant, then promote the chosen one — so a - // tenant always has at most one primary. - $this->central->execute( - 'UPDATE tenant_hosts SET is_primary = 0, updated_at = :now - WHERE tenant_id = :tid AND deleted_at IS NULL', - ['now' => $now, 'tid' => $tenantId], - ); - $this->central->execute( - 'UPDATE tenant_hosts SET is_primary = 1, updated_at = :now - WHERE tenant_id = :tid AND host_id = :hid AND deleted_at IS NULL', - ['now' => $now, 'tid' => $tenantId, 'hid' => $hostId], - ); - - $this->central->commit(); - } catch (\Throwable $e) { - if ($this->central->inTransaction()) { - $this->central->rollback(); - } - throw new RepositoryException('Failed to set primary host.', layer: 'repository.tenancy', previous: $e); - } - } - - public function softDelete(string $tenantId, int $hostId): void - { - $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - // Mangle the unique hostname on delete so the same hostname can be - // re-registered later without colliding with this soft-deleted row. - // (verification_token is already random-unique — no need to touch it.) - // The mangling is done in PHP rather than via CONCAT()/SUBSTRING() so - // the statement is driver-portable (MySQL CONCAT vs PostgreSQL/SQLite - // `||`, SUBSTRING vs substr); only a bound value crosses the wire. - $row = $this->central->queryOne( - 'SELECT hostname FROM tenant_hosts - WHERE tenant_id = :tid AND host_id = :hid AND deleted_at IS NULL', - ['tid' => $tenantId, 'hid' => $hostId], - ); - if ($row === null) { - return; // already gone / not this tenant's — nothing to delete - } - - $mangled = mb_substr((string) $row['hostname'], 0, 160) . '#del:' . $hostId . ':' . $now; - - $this->central->execute( - 'UPDATE tenant_hosts - SET deleted_at = :now, is_primary = 0, hostname = :hostname - WHERE tenant_id = :tid AND host_id = :hid AND deleted_at IS NULL', - [ - 'now' => $now, - 'hostname' => $mangled, - 'tid' => $tenantId, - 'hid' => $hostId, - ], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to remove tenant host.', layer: 'repository.tenancy', previous: $e); - } - } -} diff --git a/plugins/Tenancy/Infrastructure/Persistence/TenantRegistry.php b/plugins/Tenancy/Infrastructure/Persistence/TenantRegistry.php deleted file mode 100644 index f8cdc6a..0000000 --- a/plugins/Tenancy/Infrastructure/Persistence/TenantRegistry.php +++ /dev/null @@ -1,92 +0,0 @@ -key($tenantId); - - $cached = $this->cache->get($key); - if ($cached === self::MISS) { - return null; - } - if (is_array($cached)) { - return Tenant::fromRow($cached); - } - - $row = $this->central->queryOne( - 'SELECT tenant_id, name, slug, db_driver, db_host, db_port, db_name, - db_username, db_password_enc, db_shard, status, schema_version - FROM tenants - WHERE tenant_id = :id AND deleted_at IS NULL', - ['id' => $tenantId], - ); - - if ($row === null) { - $this->cache->set($key, self::MISS, $this->ttl); - return null; - } - - // Cache the raw row (cheaply serializable) rather than the entity. - $this->cache->set($key, $row, $this->ttl); - - return Tenant::fromRow($row); - } - - public function exists(string $tenantId): bool - { - return $this->find($tenantId)?->status->isRoutable() === true; - } - - public function listByStatus(int $status): array - { - $rows = $this->central->query( - 'SELECT tenant_id, name, slug, db_driver, db_host, db_port, db_name, - db_username, db_password_enc, db_shard, status, schema_version - FROM tenants - WHERE status = :status AND deleted_at IS NULL - ORDER BY id ASC', - ['status' => $status], - ); - - return array_map(static fn (array $r): Tenant => Tenant::fromRow($r), $rows); - } - - public function forget(string $tenantId): void - { - $this->cache->delete($this->key($tenantId)); - } - - private function key(string $tenantId): string - { - return 'tenancy:registry:' . $tenantId; - } -} diff --git a/plugins/Tenancy/Infrastructure/Provisioning/DdlTenantProvisioner.php b/plugins/Tenancy/Infrastructure/Provisioning/DdlTenantProvisioner.php deleted file mode 100644 index 41af6e4..0000000 --- a/plugins/Tenancy/Infrastructure/Provisioning/DdlTenantProvisioner.php +++ /dev/null @@ -1,144 +0,0 @@ -databaseExistsRaw($this->central, $tenant->dbDriver, $tenant->dbName); - } - - public function provision(Tenant $tenant, string $plainPassword, bool $databaseAlreadyExists): void - { - $driver = $tenant->dbDriver; - $dbName = $tenant->dbName; - - // 1. Create the isolated database (idempotent, driver-aware). - if (!$databaseAlreadyExists) { - match ($driver) { - 'pgsql' => $this->central->execute("CREATE DATABASE \"{$dbName}\""), - 'sqlsrv' => $this->central->execute("IF DB_ID('{$dbName}') IS NULL CREATE DATABASE [{$dbName}]"), - default => $this->central->execute("CREATE DATABASE IF NOT EXISTS `{$dbName}`"), - }; - } - - // 2. Create the tenant DB user, granted on its database only. - $this->provisionUser($driver, $dbName, $tenant->dbUsername, $plainPassword, $tenant->dbHost); - - // 3. Run the tenant template migrations against the new database. - $service = MigrationServiceFactory::fromConfig([ - 'driver' => $driver, - 'host' => $tenant->dbHost, - 'port' => $tenant->dbPort, - 'database' => $dbName, - 'username' => $tenant->dbUsername, - 'password' => $plainPassword, - 'paths' => [$this->templatePath], - 'transactional' => true, - ]); - $service->install(); - $service->run(); - } - - public function teardown(Tenant $tenant, bool $dropDatabase): int - { - $failed = 0; - - try { - $this->dropDatabaseUser($this->central, $tenant->dbDriver, $tenant->dbUsername, $tenant->dbHost); - } catch (\Throwable) { - $failed++; - } - - if ($dropDatabase) { - try { - $this->dropDatabase($this->central, $tenant->dbDriver, $tenant->dbName); - } catch (\Throwable) { - $failed++; - } - } - - return $failed; - } - - /** - * Create the tenant DB user (idempotent) and grant it full privileges on its - * own database only. The password cannot be parameter-bound in DDL, so it is - * escaped and inlined; identifiers are validated by the calling service. - */ - private function provisionUser(string $driver, string $dbName, string $dbUser, string $dbPass, string $dbHost): void - { - $db = $this->central; - - if ($driver === 'pgsql') { - $pass = "'" . str_replace("'", "''", $dbPass) . "'"; - $exists = $db->queryOne('SELECT 1 AS present FROM pg_roles WHERE rolname = :u', ['u' => $dbUser]); - if ($exists === null) { - $db->execute("CREATE ROLE \"{$dbUser}\" LOGIN PASSWORD {$pass}"); - } else { - $db->execute("ALTER ROLE \"{$dbUser}\" WITH LOGIN PASSWORD {$pass}"); - } - $db->execute("GRANT ALL PRIVILEGES ON DATABASE \"{$dbName}\" TO \"{$dbUser}\""); - $db->execute("ALTER DATABASE \"{$dbName}\" OWNER TO \"{$dbUser}\""); - - return; - } - - if ($driver === 'sqlsrv') { - $pass = "'" . str_replace("'", "''", $dbPass) . "'"; - $login = str_replace(']', ']]', $dbUser); - $db->execute( - "IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = '{$dbUser}') " - . "CREATE LOGIN [{$login}] WITH PASSWORD = {$pass}; " - . "ELSE ALTER LOGIN [{$login}] WITH PASSWORD = {$pass};" - ); - $db->execute( - "USE [{$dbName}]; " - . "IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = '{$dbUser}') " - . "CREATE USER [{$login}] FOR LOGIN [{$login}]; " - . "ALTER ROLE db_owner ADD MEMBER [{$login}];" - ); - - return; - } - - // MySQL / MariaDB — pin the account to the connecting host (never '%'). - $pass = "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $dbPass) . "'"; - foreach ($this->grantHosts($dbHost) as $host) { - // CREATE USER IF NOT EXISTS is a no-op — password included — when the - // account already exists, so ALTER USER forces the current credential - // (a lingering account otherwise keeps a stale password → the tenant - // connection fails "using password: YES"). - $db->execute("CREATE USER IF NOT EXISTS '{$dbUser}'@'{$host}' IDENTIFIED BY {$pass}"); - $db->execute("ALTER USER '{$dbUser}'@'{$host}' IDENTIFIED BY {$pass}"); - $db->execute("GRANT ALL PRIVILEGES ON `{$dbName}`.* TO '{$dbUser}'@'{$host}'"); - } - $db->execute('FLUSH PRIVILEGES'); - } -} diff --git a/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php b/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php deleted file mode 100644 index 7b03820..0000000 --- a/plugins/Tenancy/Infrastructure/TenantConnectionResolver.php +++ /dev/null @@ -1,208 +0,0 @@ - isolated DatabasePort. - * - * Sits on top of plugins/Database's ConnectionManager: it registers a named - * connection per tenant ("tenant:") whose config is derived from the - * central registry row, then asks the manager to resolve it. The manager - * caches the resolved adapter and builds it lazily, so reuse and lazy socket - * opening come for free. - * - * Guarantees: - * - Fail closed. Unknown / suspended / deleted / unreachable -> throw. Never - * fall back to another tenant or the central DB. - * - Per-tenant circuit breaker. After N consecutive failures the breaker - * opens and the tenant fast-fails for a cooldown window, isolating one dead - * tenant DB from the rest of the fleet. - * - * Swoole safety: this resolver is app-lifetime, but the DatabasePort it returns - * is bound into the per-request ModuleContainer by TenantContextStage and - * discarded on reset(). Two coroutines serving different tenants get different - * bindings. The resolver itself holds no per-request state. - */ -final class TenantConnectionResolver implements TenantConnectionResolverContract -{ - public function __construct( - private readonly DatabaseConnectionManagerContract $connections, - private readonly TenantRegistryContract $registry, - private readonly EncryptionPort $crypto, - private readonly CachePort $cache, - private readonly LoggerPort $logger = new NullLogger(), - private readonly int $breakerThreshold = 5, - private readonly int $breakerCooldown = 30, - /** - * Sliding window (seconds) over which consecutive failures must occur to - * trip the breaker. Without it the failure counter never expires, so rare - * blips spread over hours/days would eventually open the breaker on a - * perfectly healthy tenant. - */ - private readonly int $breakerWindow = 60, - ) {} - - public function for(string $tenantId): DatabasePort - { - $name = 'tenant:' . $tenantId; - - // ALWAYS re-validate status, even when the connection is already warm in - // this worker. The registry is cache-backed (short TTL), so this is cheap, - // and it is what makes a suspension/deletion take effect on a long-lived - // Swoole worker instead of lingering until the process restarts. A stale - // warm handle to a now-unavailable tenant is dropped so it cannot be served. - $tenant = $this->registry->find($tenantId) - ?? $this->reject($name, UnknownTenantException::for($tenantId)); - - - try { - $this->guardStatus($tenant); - } catch (TenantUnavailableException $e) { - $this->reject($name, $e); - } - - $this->guardBreaker($tenantId); - - // Reuse a healthy warm connection; otherwise build it lazily. - if (!$this->connections->has($name)) { - $this->connections->register($name, $this->configFor($tenant)); - } - - return $this->connections->connection($name); - } - - /** - * Explicitly drop any open tenant connection and forget its cached registry - * row, so a control-plane action (suspend/delete/credential rotation) takes - * effect immediately rather than waiting out the registry TTL. - */ - public function invalidate(string $tenantId): void - { - $this->registry->forget($tenantId); - $this->connections->close('tenant:' . $tenantId); - } - - /** Close any stale warm handle, then throw the routing decision. */ - private function reject(string $connectionName, \Throwable $e): never - { - $this->connections->close($connectionName); - throw $e; - } - - /** - * Record a tenant DB failure (called by TenantContextStage when a query - * against the tenant connection throws a connectivity error). Trips the - * breaker once the threshold is reached and drops the cached adapter so the - * next attempt rebuilds it. - */ - public function recordFailure(string $tenantId, \Throwable $e): void - { - $key = $this->failKey($tenantId); - $count = $this->cache->increment($key); - $this->connections->close('tenant:' . $tenantId); - - // Establish the sliding window on the FIRST failure of a window. Redis - // INCR preserves an existing TTL, so subsequent increments keep counting - // within the same window; the counter then expires if failures stop. - if ($count <= 1) { - $this->cache->set($key, $count, $this->breakerWindow); - } - - if ($count >= $this->breakerThreshold) { - $this->cache->set($this->breakerKey($tenantId), 1, $this->breakerCooldown); - $this->logger->error('Tenant DB circuit breaker opened', [ - 'tenant_id' => $tenantId, - 'failures' => $count, - 'cooldown' => $this->breakerCooldown, - 'error' => $e->getMessage(), - ]); - } - } - - /** Clear the failure counter after a healthy request. */ - public function recordSuccess(string $tenantId): void - { - $this->cache->delete($this->failKey($tenantId)); - } - - private function guardStatus(Tenant $tenant): void - { - match ($tenant->status) { - TenantStatus::Active => null, - TenantStatus::Suspended => throw TenantUnavailableException::suspended($tenant->tenantId), - TenantStatus::Deleted => throw TenantUnavailableException::deleted($tenant->tenantId), - TenantStatus::Provisioning => throw TenantUnavailableException::provisioning($tenant->tenantId), - }; - } - - private function guardBreaker(string $tenantId): void - { - if ($this->cache->has($this->breakerKey($tenantId))) { - throw TenantUnavailableException::breakerOpen($tenantId); - } - } - - private function configFor(Tenant $tenant): DatabaseConfigurationContract - { - // SQLite is file-based — no host/port/credentials to decrypt. The - // registry stores the file path in db_name (storefront/domain mode). - // Fail CLOSED when the file is absent: opening it would silently create - // an empty database and serve empty data (e.g. a just-deleted tenant - // whose row is still cached). A missing file means "not provisioned". - if ($tenant->dbDriver === 'sqlite') { - if ($tenant->dbName !== ':memory:' && !is_file($tenant->dbName)) { - throw TenantUnavailableException::provisioning($tenant->tenantId); - } - - return new SQLiteConfiguration($tenant->dbName); - } - - $password = $this->crypto->decryptString($tenant->dbPasswordEnc); - - return match ($tenant->dbDriver) { - 'pgsql' => new PostgreSQLConfiguration( - host: $tenant->dbHost, - port: $tenant->dbPort, - database: $tenant->dbName, - username: $tenant->dbUsername, - password: $password, - ), - default => new MySQLConfiguration( - host: $tenant->dbHost, - port: $tenant->dbPort, - database: $tenant->dbName, - username: $tenant->dbUsername, - password: $password, - ), - }; - } - - private function failKey(string $tenantId): string - { - return 'tenancy:fail:' . $tenantId; - } - - private function breakerKey(string $tenantId): string - { - return 'tenancy:breaker:' . $tenantId; - } -} diff --git a/plugins/Tenancy/Provider.php b/plugins/Tenancy/Provider.php deleted file mode 100644 index 62eaaac..0000000 --- a/plugins/Tenancy/Provider.php +++ /dev/null @@ -1,433 +0,0 @@ -singleton(TenantRegistryContract::class, static function ($c): TenantRegistryContract { - $manager = $c->make(DatabaseConnectionManagerContract::class); - - return new TenantRegistry( - central: $manager->default(), // central connection — never a tenant one - cache: $c->make(CachePort::class), - ttl: self::intEnv('TENANCY_REGISTRY_TTL', 60), - ); - }); - - $container->singleton(TenantConnectionResolverContract::class, static function ($c): TenantConnectionResolverContract { - return new TenantConnectionResolver( - connections: $c->make(DatabaseConnectionManagerContract::class), - registry: $c->make(TenantRegistryContract::class), - crypto: $c->make(EncryptionPort::class), - cache: $c->make(CachePort::class), - logger: self::optionalLogger($c), - breakerThreshold: self::intEnv('TENANCY_BREAKER_THRESHOLD', 5), - breakerCooldown: self::intEnv('TENANCY_BREAKER_COOLDOWN', 30), - breakerWindow: self::intEnv('TENANCY_BREAKER_WINDOW', 60), - ); - }); - - // Reads the central `tenant_hosts` table (hostname -> tenant_id) for the - // custom-domain identification mode. Pinned to the central connection. - $container->singleton(TenantHostRegistryContract::class, static function ($c): TenantHostRegistryContract { - $manager = $c->make(DatabaseConnectionManagerContract::class); - - return new TenantHostRegistry( - central: $manager->default(), // central connection — never a tenant one - cache: $c->make(CachePort::class), - ttl: self::intEnv('TENANCY_REGISTRY_TTL', 60), - ); - }); - - // Tenant identification strategy, selected by TENANCY_MODE: - // 'host' -> full Host header mapped via the tenant_hosts registry - // (custom/bring-your-own domains) - // 'domain' -> Host sub-domain label under a configured base domain - // default -> the authenticated Identity claim (SaaS/JWT model) - // The connection routing below is identical for all; only WHO the tenant - // is differs. - $container->singleton(TenantIdentifier::class, static function ($c): TenantIdentifier { - return match (self::mode()) { - 'host' => new HostTenantIdentifier($c->make(TenantHostRegistryContract::class)), - 'domain' => new DomainTenantIdentifier(self::baseDomains(), self::reservedSubdomains()), - default => new ClaimTenantIdentifier(), - }; - }); - - // ── custom-domain management (UI-driven) ───────────────────────────── - // Writes the central tenant_hosts table; DNS adapter scans live records - // to prove ownership of a domain by the verification token. - $container->bindInternal(TenantHostStore::class, static fn($c): TenantHostStore => - new TenantHostRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - - $container->bindInternal(DnsResolver::class, static fn(): DnsResolver => new SystemDnsResolver()); - - $container->bind(TenantHostServiceContract::class, static fn($c): TenantHostServiceContract => - new TenantHostService( - hosts: $c->make(TenantHostStore::class), - dns: $c->make(DnsResolver::class), - audit: $c->make(AuditServiceContract::class), - registry: $c->make(TenantHostRegistryContract::class), - challengePrefix: (string) (env('TENANCY_DNS_CHALLENGE_PREFIX') ?: '_psp-verify'), - valuePrefix: (string) (env('TENANCY_DNS_VALUE_PREFIX') ?: 'psp-verify='), - maxHostsPerTenant: self::intEnv('TENANCY_MAX_HOSTS_PER_TENANT', 25), - )); - - $container->bindInternal(TenantHostController::class, static fn($c): TenantHostController => - new TenantHostController($c->make(TenantHostServiceContract::class))); - - $container->bind( - TenantContextStage::class, - static fn($c): TenantContextStage => - new TenantContextStage( - $c->make(TenantConnectionResolverContract::class), - $c->make(TenantIdentifier::class), - ) - ); - - // ── tenant-selection flow (central control plane) ──────────────────── - // Membership + audit read/write the CENTRAL connection (user_tenants / - // audit_log live in the control-plane DB), pinned via the manager default. - $container->bindInternal(MembershipReader::class, static fn($c): MembershipReader => - new MembershipRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - - // Audit is now owned by the shared Audit plugin (solves audit.trail). - // Tenancy records through its published AuditServiceContract instead of - // writing `audit_log` itself — see requires: ["audit.trail"]. - - // Control plane only — verifies seats + audits. No Auth dependency: - // token minting lives in TenantController, which keeps the container - // graph acyclic (AuthService → UserService → MembershipService). - $container->bind(MembershipServiceContract::class, static fn($c): MembershipServiceContract => - new MembershipService( - memberships: $c->make(MembershipReader::class), - audit: $c->make(AuditServiceContract::class), - )); - - // The HTTP boundary composes the verified seat with the Auth module - // (token mint) and User's tenant-profile reader (the `name` claim from - // the tenant user_profiles row — the User plugin owns that table's SQL). - $container->bindInternal(TenantController::class, static fn($c): TenantController => - new TenantController( - memberships: $c->make(MembershipServiceContract::class), - auth: $c->make(AuthServiceContract::class), - profiles: $c->has(\Plugins\User\API\Contracts\TenantProfileReaderContract::class) - ? $c->make(\Plugins\User\API\Contracts\TenantProfileReaderContract::class) - : null, - tokenTtl: self::intEnv('TENANCY_TOKEN_TTL', 3600), - )); - - // ── tenant administration (control-plane CRUD) ─────────────────────── - // Provisions/updates/de-provisions tenants over HTTP — the JSON twin of - // the tenant:create / tenant:delete CLI commands. The service orchestrates - // two internal ports: persistence (DatabasePort only) and provisioning - // (DDL + template migrations). Both pin to the CENTRAL connection. - $container->bindInternal(TenantWriteStore::class, static fn($c): TenantWriteStore => - new TenantAdminRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - - $container->bindInternal(TenantProvisioner::class, static function ($c): TenantProvisioner { - $template = env('TENANCY_TEMPLATE_PATH'); - - return new DdlTenantProvisioner( - central: $c->make(DatabaseConnectionManagerContract::class)->default(), - templatePath: (is_string($template) && $template !== '') - ? $template - : __DIR__ . '/database/tenant-template', - ); - }); - - $container->bind(TenantAdminServiceContract::class, static fn($c): TenantAdminServiceContract => - new TenantAdminService( - store: $c->make(TenantWriteStore::class), - provisioner: $c->make(TenantProvisioner::class), - registry: $c->make(TenantRegistryContract::class), - crypto: $c->make(EncryptionPort::class), - identity: $c->make(\AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity::class), - )); - - $container->bindInternal(TenantAdminController::class, static fn($c): TenantAdminController => - new TenantAdminController($c->make(TenantAdminServiceContract::class))); - - // ── invitations (email onboarding) ─────────────────────────────────── - $container->bindInternal(InvitationStore::class, static fn($c): InvitationStore => - new InvitationRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - - $container->bindInternal(MembershipWriter::class, static fn($c): MembershipWriter => - new MembershipRepository($c->make(DatabaseConnectionManagerContract::class)->default())); - - - $container->bindInternal(AssignTenantMembershipOnUserRegistered::class, static fn($c): AssignTenantMembershipOnUserRegistered => - new AssignTenantMembershipOnUserRegistered($c->make(MembershipWriter::class))); - - $container->bind(InvitationServiceContract::class, static fn($c): InvitationServiceContract => - new InvitationService( - invitations: $c->make(InvitationStore::class), - memberships: $c->make(MembershipWriter::class), - audit: $c->make(AuditServiceContract::class), - )); - - // ── HTTP boundary for the invitation flow ──────────────────────────── - $container->bindInternal(InvitationController::class, static fn($c): InvitationController => - new InvitationController( - $c->make(InvitationServiceContract::class), - $c->make(UserServiceContract::class), - )); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Route to the tenant DB before RouteFilterStage / ExecuteStage touch - // tenant data. Ordering among after.load hooks (lower = outer): - // StartSessionStage (20) → SessionAuthStage (22) → TenantContextStage (23) - // → QueuedCookiesStage (25). - // Running AFTER session auth lets a SESSION-scoped tenant (Identity.tenantId - // populated from the session) route the connection, not just JWT/Host. It - // stays OUTER of the cookie-flush stage so the remembered-tenant cookie it - // queues is still written to the response. All after.load hooks run before - // the dedicated RouteFilterStage regardless of priority. - // CONTROL-PLANE DEPLOYMENTS (TENANCY_CONTROL_PLANE=true) skip this hook. - // - // A super-admin / control-plane host administers the tenant fleet from - // the CENTRAL database: it lists tenants, provisions them, and reaches - // into any one of them explicitly. It is never itself scoped to a - // tenant. Registering the stage there would make every request either - // 500 (route did not load Tenancy → TenantIdentifier unbound → the - // guard below throws) or 404 (loaded, but no tenant resolves on an - // admin host) — so a control plane could not serve HTTP at all. - // - // Everything else the plugin publishes stays available: the registry, - // the connection resolver, the admin/membership/invitation services and - // the provisioning commands. Only per-request tenant ROUTING is off, so - // DatabasePort keeps pointing at central — which is exactly what - // control-plane code already pins explicitly. - // - // Leave it false (the default) for any tenant-serving deployment. - if (!self::controlPlane()) { - $http->hook('after.load', TenantContextStage::class, priority: 10); - } - - // Reusable declarative guard: a route that touches tenant-only tables - // opts in with "filters": ["auth", "tenant"] to fail clean (409) when no - // tenant is active, instead of hitting the central DB and 500-ing. - $http->filter('tenant', \Plugins\Tenancy\Infrastructure\Http\Stages\RequireTenantStage::class); - - // Assign a self-signup user to their originating tenant. The User plugin - // emits `user.registered` (via its outbox); the tenant rides on the event - // payload. The project binds the listener in the CoreContainer with a - // central-connection MembershipWriter (EventBus resolves listeners there). - $events->subscribe('user.registered', \Plugins\Tenancy\Application\Listeners\AssignTenantMembershipOnUserRegistered::class); - - // The tenant:create / tenants:migrate provisioning commands are SaaS - // control-plane tools (they CREATE DATABASE, encrypt credentials, drive - // the central registry) and depend on the Auth/Database control plane. - // They are irrelevant in domain mode — where tenants are provisioned by - // the project's own tooling — so only register them in claim mode. - // - // Their constructors need MODULE-SCOPED contracts (DatabaseConnectionManager, - // TenantRegistry) that the CoreContainer cannot autowire — so the bare - // class-string path is silently dropped by CliPipeline::instantiate(). - // Build a scoped ModuleContainer on the CLI path (deferred so HTTP/worker - // builds never pay for it) and register the commands as ready instances. - if (self::mode() !== 'domain') { - $cli->defer(static function (CliPipeline $cli): void { - $c = new ModuleContainer($cli->container()); - - // Register the providers whose public contracts the commands need. - $c->setScope('database.management'); - (new \Plugins\Database\Provider())->register($c); - $c->setScope((new \Plugins\Crypto\Provider())->solves()); - (new \Plugins\Crypto\Provider())->register($c); - // Audit publishes AuditServiceContract, which the Tenancy services - // built below (TenantHostService, …) now depend on. - $c->setScope((new \Plugins\Audit\Provider())->solves()); - (new \Plugins\Audit\Provider())->register($c); - $c->setScope('tenancy.routing'); - (new self())->register($c); - - $connections = $c->make(DatabaseConnectionManagerContract::class); - $crypto = $c->make(EncryptionPort::class); - - $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\CreateTenantCommand($connections, $crypto)); - $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\MigrateTenantsCommand( - $c->make(TenantRegistryContract::class), - $connections, - $crypto, - )); - $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\DeleteTenantCommand($connections)); - $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\RememberTenantCommand($connections)); - $cli->command(new \Plugins\Tenancy\Infrastructure\Cli\AddTenantHostCommand( - $c->make(TenantHostServiceContract::class), - $connections, - )); - }); - } - } - - private static function optionalLogger(mixed $container): LoggerPort - { - try { - $logger = $container->make(LoggerPort::class); - - return $logger instanceof LoggerPort ? $logger : new NullLogger(); - } catch (\Throwable) { - return new NullLogger(); - } - } - - private static function intEnv(string $key, int $default): int - { - $value = env($key); - - return ($value === false || $value === null || $value === '') ? $default : (int) $value; - } - - /** Tenant identification mode: 'domain' (Host sub-domain) or 'claim' (default). */ - private static function mode(): string - { - return strtolower((string) (env('TENANCY_MODE') ?: 'claim')); - } - - /** - * TENANCY_CONTROL_PLANE — this deployment ADMINISTERS the tenant fleet - * rather than serving one, so per-request tenant routing is switched off - * (see boot()). Defaults to false: a deployment is tenant-serving unless it - * says otherwise, so an operator can never lose tenant isolation by - * forgetting to set something. - */ - private static function controlPlane(): bool - { - return filter_var(env('TENANCY_CONTROL_PLANE') ?? false, \FILTER_VALIDATE_BOOL); - } - - /** - * Base domains a tenant sub-domain hangs off, from TENANCY_BASE_DOMAINS - * (comma-separated). Only consulted in domain mode. - * - * @return string[] - */ - private static function baseDomains(): array - { - $raw = (string) (env('TENANCY_BASE_DOMAINS') ?: ''); - - return array_values(array_filter(array_map('trim', explode(',', $raw)))); - } - - /** - * Sub-domain labels that are NEVER tenants (map to central) in domain mode, - * from TENANCY_RESERVED_SUBDOMAINS. Sensible infra/marketing defaults apply - * when unset so hosts like www/api do not 404 as unknown tenants. - * - * @return string[] - */ - private static function reservedSubdomains(): array - { - $raw = env('TENANCY_RESERVED_SUBDOMAINS'); - if ($raw === false || $raw === null || trim((string) $raw) === '') { - return ['www', 'api', 'admin', 'app', 'cdn', 'static', 'assets', 'mail']; - } - - return array_values(array_filter(array_map('trim', explode(',', (string) $raw)))); - } -} diff --git a/plugins/Tenancy/README.md b/plugins/Tenancy/README.md deleted file mode 100644 index 1c468ec..0000000 --- a/plugins/Tenancy/README.md +++ /dev/null @@ -1,238 +0,0 @@ -# Tenancy — Multi-Tenant Control Plane - -Database-per-tenant routing for the AlfacodeTeam PhpServicePlatform. Maps the -authenticated `Identity.tenantId` to an **isolated tenant database** and rebinds -`DatabasePort` per request, so every repository transparently talks to the -correct tenant DB. Built on top of `plugins/Database`'s `ConnectionManager`. - -- **solves:** `tenancy.routing` -- **requires:** `database.management` (stage path only — its routes carry `auth.identity`/`user.management`/`audit.trail` as route-level `requires[]`) -- **exposes:** `TenantRegistryContract`, `TenantConnectionResolverContract`, `MembershipServiceContract`, `InvitationServiceContract` - -## Two planes - -| Plane | DB | Tables | -|---|---|---| -| Control (central) | one, always connected | `users`, `tenants`, `user_tenants` (+ invitations/audit) | -| Data (per tenant) | one per tenant, on demand | pure business domain — `projects`, `tasks`, … **no auth, no `tenant_id` column** | - -## Wiring - -1. **Run central migrations** (against the central connection): - ``` - hkm migrate:run # creates tenants, user_tenants (users lives in plugins/User) - ``` - -2. **Register as an ESSENTIAL module** so every request is routed — declared by - the PROJECT in `proj.json` (the bootstrap wires - `->withEssentialModules(EntryHelpers::projectEssentials($projectRoot))`): - ```jsonc - // proj.json - "essentials": ["tenancy.routing"] - ``` - The domain resolves to the provider at `build()` (unknown domain = boot - failure), and essentials load their transitive `requires[]` — so - `database.management` comes along automatically. `EncryptionPort` and - `CachePort` are core ports (bootstrap `withPorts`) used by the - resolver/registry — ensure both are bound. - -3. **Mint a tenant-scoped Identity** in your Auth layer. After the user selects a - tenant, re-check `user_tenants` and put the tenant in the JWT `tnt` claim; the - Auth security layer sets `Identity.tenantId` from it. `TenantContextStage` - (registered at `after.load`) does the rest. - - > The membership re-check on every request lives in the Auth layer, not here — - > a revoked `user_tenants` row must drop access before the JWT expires. - -## Control-plane tables - -Central migrations (run on the central connection via `hkm migrate:run`): - -| Table | Role | -|---|---| -| `tenants` | registry → connection coordinates (password encrypted) | -| `user_tenants` | M:N membership: user ↔ tenant + role + status | -| `tenant_invitations` | email onboarding; SHA-256 token only; converts to a membership on accept | -| `audit_log` | append-only trail (login, `tenant.switch`, `tenant.create`, …) | - -## Tenant-selection flow (`MembershipServiceContract`) - -Turns an authenticated but *unscoped* user into a tenant-scoped session. Exposed -as routes (both behind the `auth` filter): - -``` -GET /ajx/me/tenants → the tenant picker (active seats only) -POST /ajx/tenants/{tenantId}/select → re-mint a tenant-scoped token -``` - -`selectTenant()` **re-verifies** the membership against central `user_tenants` -(never trusts a client-supplied tenant id), audits `tenant.switch`, and returns -the verified seat (`TenantSummary`). Tenancy is control plane ONLY — it does -NOT mint credentials: `TenantController` composes the seat with the Auth -module (`AuthServiceContract::issueJwt`, `tnt` claim + `roles` + the `name` -claim read via User's published `TenantProfileReaderContract`) and builds the -response: - -```php -$seat = $memberships->selectTenant($identity->userId, $tenantId, $request->ip()); -// controller: issueJwt(userId, ['tnt' => ..., 'roles' => [$seat->role], 'name' => ...]) -// → { token, tokenType: "Bearer", tenantId, role, expiresIn } -``` - -The client sends the returned token on subsequent requests; `TenantContextStage` -routes them to the tenant database. A revoked/suspended seat fails `selectTenant` -with `403` (audited `tenant.switch_denied`) and — because the Auth layer re-checks -membership per request — also loses access on an already-issued token before it -expires. `TENANCY_TOKEN_TTL` (default 3600s) sets the scoped-token lifetime. - -## Invitations (`InvitationServiceContract`) - -Email-based onboarding that decouples "invited" from "has an account". - -```php -$res = $invitations->invite($tenantId, 'alice@example.com', 'member', $inviterUserId); -// → InvitationResult{ token, … } — embed $res->token in the emailed accept link (shown ONCE) - -$tenantId = $invitations->accept($rawToken, $identity->userId, $userVerifiedEmail, $ip); -// validates (pending, not expired, email matches), creates/activates the user_tenants -// seat (idempotent), marks the invite accepted, audits member.join. - -$invitations->revoke($rawToken); -``` - -Only the SHA-256 of the token is stored. `accept()` REQUIRES the authenticated -user's verified email to match the invited address (an invite for alice@ cannot -be claimed by bob@). - -Wired endpoint (behind the `auth` filter; the email is read from the User -identity store, never the request body): - -``` -POST /ajx/invitations/accept { "token": "…" } → { "tenantId": "…" } -``` - -This is why the invitation route carries `"requires": ["user.management"]` in -`module.json` — `InvitationController` resolves the caller's verified email via -`UserServiceContract` (route-level, so it loads only when the endpoint is hit). - -## Refresh tokens — moved to `Plugins\Auth` - -Refresh tokens are an **authentication** concern, so they now live in -`Plugins\Auth` (`RefreshTokenServiceContract`, table `refresh_tokens`, endpoints -`POST /auth/refresh` + `/auth/refresh/logout`). See `docs/ai-context/25_AUTH.md`. - -The relocated flow is **tenant-agnostic**: `tenantId` rides through as a -passthrough hint for the access token's `tnt` claim but is NOT re-verified on -refresh. The tenant seat re-check (a revoked seat can't get back in) lives HERE, -in the tenant-**selection** flow (`POST /ajx/tenants/{id}/select`), not on refresh. - -## Provisioning & migrations - -``` -hkm tenants:create --name="Acme" --slug=acme \ - --db-name=tnt_acme --db-user=acme --db-password=secret \ - --db-host=127.0.0.1 --db-port=3306 - -hkm tenants:migrate # apply template migrations to all active tenants -hkm tenants:migrate --tenant= # one tenant -hkm tenants:migrate --pretend # print SQL, change nothing -``` - -The tenant template lives in `database/tenant-template/`. Override with -`TENANCY_TEMPLATE_PATH` or `--template`. Each tenant DB keeps its own -`let_migrations` table; the central `tenants.schema_version` mirrors the latest -applied batch for fleet-wide drift visibility. A failing tenant is skipped, not -fatal — the run is resumable. - -### `var/tenants.json` — default tenant for the CLI - -A successful `tenant:create` records the tenant in the project's -`var/tenants.json` (`Plugins\Tenancy\Support\TenantsFile`) and makes it the -**default** (last created wins). Commands that target one tenant then work -without `--tenant`/`--slug`: - -``` -hkm tenant:create --name="Acme" --slug=acme ... # recorded as default -hkm tenant:host:add --host=acme.localhost --verified # → default tenant -hkm tenant:delete --drop-database # → default tenant -``` - -Tenants provisioned BEFORE this existed (or after a `var/` wipe — it is -disposable) are backfilled with `tenant:remember`: - -``` -hkm tenant:remember # only one tenant registered → recorded; else interactive pick -hkm tenant:remember --slug=acme # one tenant by slug (becomes the default) -hkm tenant:remember --all # every registered tenant (last = default) -``` - -The file is a convenience HINT only — the central `tenants` table stays the -source of truth. Every command re-validates the recorded id against the -registry and silently drops a stale entry (e.g. a tenant deleted elsewhere). -`tenant:delete` also removes the entry on success; the default falls back to -the last remaining recorded tenant. `tenant:migrate` needs no id either way — -it fleet-migrates every active tenant by default. - -## Isolation guarantees - -- **Fail closed.** Unknown / suspended / deleted / unreachable tenant → throw. - Never falls back to another tenant or to central. Status is re-validated on - **every** request (the registry is cache-backed, so it's cheap) — including - when the connection is already warm in a long-lived worker — so a suspension or - deletion takes effect within `TENANCY_REGISTRY_TTL`, not "after the next worker - restart". A control-plane change can call `resolver->invalidate($tenantId)` to - drop the warm handle + cached row immediately. -- **Per-tenant circuit breaker.** After `TENANCY_BREAKER_THRESHOLD` consecutive - **connectivity** failures within `TENANCY_BREAKER_WINDOW` seconds, the tenant - fast-fails for `TENANCY_BREAKER_COOLDOWN` seconds, isolating one dead tenant DB - from the fleet. Only genuine connection faults (`ConnectionException` with a - connect / connection_lost / pool_acquire operation) feed the breaker — a bad - query or domain error does not trip a healthy tenant. The failure counter is a - sliding window, so sporadic blips never accumulate into a false trip. -- **Swoole-safe.** The resolved tenant `DatabasePort` is bound into the - per-request `ModuleContainer` and discarded on `reset()`; the tenant id rides - on the immutable `Request`/`Identity`, never a static or `CoreContainer`. - -## Config (`module.json`) - -| Env | Default | Meaning | -|---|---|---| -| `TENANCY_MODE` | `claim` | tenant identification: `claim` (Identity.tenantId) \| `domain` (Host sub-domain label) \| `host` (full Host via `tenant_hosts`) | -| `TENANCY_BASE_DOMAINS` | — | domain mode: comma-separated base domains a tenant label hangs off | -| `TENANCY_RESERVED_SUBDOMAINS` | `www,api,admin,…` | domain mode: labels that are never tenants (map to central) | -| `TENANCY_REGISTRY_TTL` | `60` | registry cache TTL (s) | -| `TENANCY_BREAKER_THRESHOLD` | `5` | connectivity failures before the breaker opens | -| `TENANCY_BREAKER_WINDOW` | `60` | sliding window (s) failures must occur within | -| `TENANCY_BREAKER_COOLDOWN` | `30` | breaker open window (s) | -| `TENANCY_TEMPLATE_PATH` | bundled | tenant template migrations path | - -## Strict routing — every host is a tenant - -Routing is **strict**: every request must resolve to a tenant (remembered -cookie hint first — principal-bound — then the `TENANCY_MODE` identifier). A -request that cannot be scoped — an unknown host, or no tenant claim/cookie — -**fails closed with 404**; there is no unscoped passthrough to the central -connection. Register every served host (`tenant:host:add --verified` in -host mode). Control-plane code that needs central pins it explicitly via the -`ConnectionManager` default. - -## Activation & per-request cost - -Tenancy must register on EVERY request — the project declares it in `proj.json`: -`"essentials": ["tenancy.routing"]` (resolved by `Kernel::withEssentialModules()`; -an unknown domain fails the boot). Module-level `requires` is just -`["database.management"]` — the always-on stage path — so the every-request -graph stays at two modules; the selection/admin/invitation/host routes pull -`auth.identity` / `user.management` / `audit.trail` via route-level -`requires[]` only when hit. A single-tenant project must leave Tenancy out of -`withModules` entirely (not merely out of essentials) — the always-on stage -fails loudly when the module never registered. - -## Swoole connection pooling (optional optimization) - -By default `ConnectionManager` is request-scoped, so tenant sockets aren't reused -across requests. For long-lived OpenSwoole workers, bind `ConnectionManager` (and -the resolver) into the **CoreContainer** in bootstrap so resolved tenant adapters -persist per worker. Cap with an LRU eviction of idle tenant connections so a -worker serving thousands of tenants never holds thousands of open sockets — and -front the DB tier with ProxySQL/PgBouncer under PHP-FPM. diff --git a/plugins/Tenancy/Support/TenantsFile.php b/plugins/Tenancy/Support/TenantsFile.php deleted file mode 100644 index 2435db4..0000000 --- a/plugins/Tenancy/Support/TenantsFile.php +++ /dev/null @@ -1,120 +0,0 @@ -", - * "tenants": [ { "tenant_id": "…", "slug": "acme", "name": "Acme Inc" } ] } - * - * `default` is the most recently created tenant (last create wins). - */ -final class TenantsFile -{ - public static function path(): string - { - return Paths::var('tenants.json'); - } - - /** Upsert a tenant and make it the default (last created wins). */ - public static function remember(string $tenantId, string $slug, string $name): void - { - $data = self::read(); - $data['tenants'] = array_values(array_filter( - $data['tenants'], - static fn (array $t): bool => $t['tenant_id'] !== $tenantId, - )); - $data['tenants'][] = ['tenant_id' => $tenantId, 'slug' => $slug, 'name' => $name]; - $data['default'] = $tenantId; - self::write($data); - } - - /** Drop a tenant; the default falls back to the last remaining entry. */ - public static function forget(string $tenantId): void - { - $data = self::read(); - $data['tenants'] = array_values(array_filter( - $data['tenants'], - static fn (array $t): bool => $t['tenant_id'] !== $tenantId, - )); - if ($data['default'] === $tenantId) { - $last = end($data['tenants']); - $data['default'] = $last === false ? '' : $last['tenant_id']; - } - self::write($data); - } - - /** @return list */ - public static function all(): array - { - return self::read()['tenants']; - } - - /** - * The tenant other commands should act on when none is named: the recorded - * default when still present, else the only entry, else null. - * - * @return array{tenant_id: string, slug: string, name: string}|null - */ - public static function defaultTenant(): ?array - { - $data = self::read(); - foreach ($data['tenants'] as $t) { - if ($t['tenant_id'] === $data['default']) { - return $t; - } - } - - return \count($data['tenants']) === 1 ? $data['tenants'][0] : null; - } - - /** @return array{tenants: list, default: string} */ - private static function read(): array - { - $raw = @file_get_contents(self::path()); - $data = \is_string($raw) ? json_decode($raw, true) : null; - - $tenants = []; - foreach ((\is_array($data) ? ($data['tenants'] ?? []) : []) as $t) { - if (\is_array($t) && \is_string($t['tenant_id'] ?? null) && $t['tenant_id'] !== '') { - $tenants[] = [ - 'tenant_id' => $t['tenant_id'], - 'slug' => \is_string($t['slug'] ?? null) ? $t['slug'] : '', - 'name' => \is_string($t['name'] ?? null) ? $t['name'] : '', - ]; - } - } - - return [ - 'tenants' => $tenants, - 'default' => \is_array($data) && \is_string($data['default'] ?? null) ? $data['default'] : '', - ]; - } - - /** @param array{tenants: list, default: string} $data */ - private static function write(array $data): void - { - $path = self::path(); - $dir = \dirname($path); - if (!is_dir($dir)) { - @mkdir($dir, 0775, true); - } - @file_put_contents( - $path, - json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) . "\n", - \LOCK_EX, - ); - } -} diff --git a/plugins/Tenancy/Support/Token.php b/plugins/Tenancy/Support/Token.php deleted file mode 100644 index 88a388e..0000000 --- a/plugins/Tenancy/Support/Token.php +++ /dev/null @@ -1,45 +0,0 @@ -create('tenants', static function ($t) { - $t->id(); - $t->char('tenant_id', 31)->comment('UUID/ULID public identifier'); - $t->string('name', 120); - $t->string('slug', 63)->comment('DNS/db-safe; ^[a-z0-9-]+$'); - - // Connection coordinates — many-DBs-one-server AND cross-server sharding. - $t->string('db_driver', 16)->default('mysql'); - $t->string('db_host', 191); - $t->unsignedSmallInteger('db_port')->default(3306); - $t->string('db_name', 64)->comment('physical database, e.g. tnt_acme'); - $t->string('db_username', 64); - $t->text('db_password_enc')->comment('encrypted via EncryptionPort — NEVER plaintext'); - $t->string('db_shard', 32)->nullable()->comment('logical shard/cluster id for ops'); - - $t->tinyInteger('status')->unsigned()->default(2) - ->comment('1=active,2=provisioning,3=suspended,4=deleted'); - $t->unsignedInteger('schema_version')->default(0) - ->comment('last applied tenant migration batch'); - - // Optional subscription metadata. - $t->string('plan', 32)->default('free'); - $t->timestamp('trial_ends_at')->nullable(); - $t->json('settings')->nullable(); - - $t->unsignedInteger('version')->default(1)->comment('optimistic-lock version'); - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP')->onUpdateCurrentTimestamp(); - $t->softDeletes(); - - $t->unique(['tenant_id'], 'uniq_tenant_id'); - $t->unique(['slug'], 'uniq_slug'); - $t->index(['status'], 'idx_status'); - $t->index(['db_shard', 'status'], 'idx_shard_status'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('tenants'); - } -}; diff --git a/plugins/Tenancy/database/migrations/2026_06_22_000002_create_user_tenants_table.php b/plugins/Tenancy/database/migrations/2026_06_22_000002_create_user_tenants_table.php deleted file mode 100644 index 03eebc1..0000000 --- a/plugins/Tenancy/database/migrations/2026_06_22_000002_create_user_tenants_table.php +++ /dev/null @@ -1,52 +0,0 @@ -create('user_tenants', static function ($t) { - $t->id(); - $t->char('user_id', 31); - $t->char('tenant_id', 31); - $t->string('role', 32)->default('member')->comment('owner|admin|member|viewer'); - $t->tinyInteger('status')->unsigned()->default(1) - ->comment('1=active,2=invited,3=suspended'); - $t->timestamp('joined_at')->nullable(); - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP')->onUpdateCurrentTimestamp(); - - $t->unique(['user_id', 'tenant_id'], 'uniq_user_tenant'); - $t->index(['tenant_id', 'status'], 'idx_tenant_status'); // list members of a tenant - $t->index(['user_id', 'status'], 'idx_user_status'); // list my tenants (login hot path) - - $t->foreign('user_id')->references('user_id')->on('users')->onDelete('cascade'); - $t->foreign('tenant_id')->references('tenant_id')->on('tenants')->onDelete('cascade'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('user_tenants'); - } -}; diff --git a/plugins/Tenancy/database/migrations/2026_06_22_000003_create_tenant_invitations_table.php b/plugins/Tenancy/database/migrations/2026_06_22_000003_create_tenant_invitations_table.php deleted file mode 100644 index 0419977..0000000 --- a/plugins/Tenancy/database/migrations/2026_06_22_000003_create_tenant_invitations_table.php +++ /dev/null @@ -1,53 +0,0 @@ -create('tenant_invitations', static function ($t) { - $t->id(); - $t->char('invite_id', 31); - $t->char('tenant_id', 31); - $t->string('email', 150); - $t->string('role', 32)->default('member')->comment('owner|admin|member|viewer'); - $t->char('token_hash', 64)->comment('SHA-256 of the invite token — never store raw'); - $t->char('invited_by', 31)->comment('central users.user_id of the inviter'); - $t->tinyInteger('status')->unsigned()->default(1) - ->comment('1=pending,2=accepted,3=revoked,4=expired'); - $t->timestamp('expires_at'); - $t->timestamp('accepted_at')->nullable(); - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP')->onUpdateCurrentTimestamp(); - - $t->unique(['invite_id'], 'uniq_invite_id'); - $t->unique(['token_hash'], 'uniq_token_hash'); - // One live invite per (tenant,email): enforced in the service, indexed here. - $t->index(['tenant_id', 'email', 'status'], 'idx_tenant_email_status'); - $t->index(['email', 'status'], 'idx_email_status'); - - $t->foreign('tenant_id')->references('tenant_id')->on('tenants')->onDelete('cascade'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('tenant_invitations'); - } -}; diff --git a/plugins/Tenancy/database/migrations/2026_06_22_000006_create_tenant_hosts_table.php b/plugins/Tenancy/database/migrations/2026_06_22_000006_create_tenant_hosts_table.php deleted file mode 100644 index 387e078..0000000 --- a/plugins/Tenancy/database/migrations/2026_06_22_000006_create_tenant_hosts_table.php +++ /dev/null @@ -1,59 +0,0 @@ -create('tenant_hosts', static function ($t) { - $t->id('host_id'); - $t->char('tenant_id', 31)->comment('owning tenant — resolves Host → tenant'); - $t->string('hostname', 191)->comment('FQDN domain or subdomain, lower-case, no port'); - $t->string('ip_address', 45)->nullable()->comment('expected A/AAAA target for verification'); - - $t->tinyInteger('status')->unsigned()->default(0) - ->comment('0=pending,1=verified,2=failed'); - $t->char('verification_token', 64)->comment('DNS/HTTP ownership challenge token'); - $t->timestamp('verified_at')->nullable(); - - $t->boolean('is_primary')->default(false) - ->comment('canonical host for this tenant (redirect target)'); - - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP')->onUpdateCurrentTimestamp(); - $t->softDeletes(); - - $t->unique(['tenant_id', 'hostname'], 'uniq_tenant_hostname'); - $t->unique(['hostname'], 'uniq_hostname'); - $t->unique(['verification_token'], 'uniq_verification_token'); - $t->index(['tenant_id'], 'idx_tenant_id'); - $t->index(['status'], 'idx_status'); - - $t->foreign('tenant_id')->references('tenant_id')->on('tenants')->onDelete('cascade'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('tenant_hosts'); - } -}; diff --git a/plugins/Tenancy/database/tenant-template/.gitkeep b/plugins/Tenancy/database/tenant-template/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/Tenancy/module.json b/plugins/Tenancy/module.json deleted file mode 100644 index dec72f5..0000000 --- a/plugins/Tenancy/module.json +++ /dev/null @@ -1,273 +0,0 @@ -{ - "name": "tenancy", - "version": "1.0.0", - "solves": "tenancy.routing", - "type": "module", - "description": "Multi-tenant control plane: tenant registry + per-tenant database routing. Identifies the tenant by TENANCY_MODE \u2014 'claim' (default: authenticated Identity.tenantId, the SaaS/JWT model) or 'domain' (the Host sub-domain, the anonymous storefront model) \u2014 then rebinds an isolated tenant DatabasePort into the request container so every repository talks to the correct tenant database. Database-per-tenant isolation (MySQL/PostgreSQL/SQLite) on top of plugins/Database ConnectionManager.", - "requires": [ - "database.management" - ], - "views": "resources/views", - "exposes": [ - "TenantRegistryContract", - "TenantHostRegistryContract", - "TenantHostServiceContract", - "TenantConnectionResolverContract", - "MembershipServiceContract", - "InvitationServiceContract", - "TenantAdminServiceContract" - ], - "routes": [ - { - "method": "GET", - "path": "/tenants", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@index", - "requires": [ - "http.pageflow" - ] - }, - { - "method": "GET", - "path": "/tenants/manage", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@manage", - "requires": [ - "http.pageflow" - ] - }, - { - "method": "GET", - "path": "/tenants/create", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@create", - "requires": [ - "http.pageflow" - ] - }, - { - "method": "GET", - "path": "/tenants/{tenantId}/edit", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@edit", - "requires": [ - "http.pageflow" - ] - }, - { - "method": "GET", - "path": "/tenant/hosts", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantPageController@hosts", - "requires": [ - "http.pageflow" - ] - }, - { - "method": "GET", - "path": "/ajx/admin/tenants", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@index", - "filters": [ - "auth" - ] - }, - { - "method": "POST", - "path": "/ajx/admin/tenants", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@store", - "filters": [ - "auth" - ] - }, - { - "method": "GET", - "path": "/ajx/admin/tenants/{tenantId}", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@show", - "filters": [ - "auth" - ] - }, - { - "method": "PUT", - "path": "/ajx/admin/tenants/{tenantId}", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@update", - "filters": [ - "auth" - ] - }, - { - "method": "DELETE", - "path": "/ajx/admin/tenants/{tenantId}", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantAdminController@destroy", - "filters": [ - "auth" - ] - }, - { - "method": "GET", - "path": "/ajx/me/tenants", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantController@mine", - "filters": [ - "auth" - ], - "requires": [ - "auth.identity", - "user.management", - "audit.trail" - ] - }, - { - "method": "POST", - "path": "/ajx/tenants/{tenantId}/select", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantController@select", - "filters": [ - "auth" - ], - "requires": [ - "auth.identity", - "user.management", - "audit.trail" - ] - }, - { - "method": "POST", - "path": "/ajx/invitations/accept", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\InvitationController@accept", - "filters": [ - "auth" - ], - "requires": [ - "user.management", - "audit.trail" - ] - }, - { - "method": "GET", - "path": "/ajx/tenant/hosts", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@index", - "filters": [ - "auth" - ], - "requires": [ - "audit.trail" - ] - }, - { - "method": "POST", - "path": "/ajx/tenant/hosts", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@store", - "filters": [ - "auth" - ], - "requires": [ - "audit.trail" - ] - }, - { - "method": "GET", - "path": "/ajx/tenant/hosts/{hostId}/instructions", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@instructions", - "filters": [ - "auth" - ], - "requires": [ - "audit.trail" - ] - }, - { - "method": "POST", - "path": "/ajx/tenant/hosts/{hostId}/verify", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@verify", - "filters": [ - "auth" - ], - "requires": [ - "audit.trail" - ] - }, - { - "method": "POST", - "path": "/ajx/tenant/hosts/{hostId}/primary", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@makePrimary", - "filters": [ - "auth" - ], - "requires": [ - "audit.trail" - ] - }, - { - "method": "DELETE", - "path": "/ajx/tenant/hosts/{hostId}", - "handler": "Plugins\\Tenancy\\Infrastructure\\Http\\Controllers\\TenantHostController@destroy", - "filters": [ - "auth" - ], - "requires": [ - "audit.trail" - ] - } - ], - "config": [ - { - "key": "TENANCY_MODE", - "type": "string", - "required": false - }, - { - "key": "TENANCY_DNS_CHALLENGE_PREFIX", - "type": "string", - "required": false - }, - { - "key": "TENANCY_DNS_VALUE_PREFIX", - "type": "string", - "required": false - }, - { - "key": "TENANCY_MAX_HOSTS_PER_TENANT", - "type": "int", - "required": false - }, - { - "key": "TENANCY_BASE_DOMAINS", - "type": "string", - "required": false - }, - { - "key": "TENANCY_RESERVED_SUBDOMAINS", - "type": "string", - "required": false - }, - { - "key": "TENANCY_REGISTRY_TTL", - "type": "int", - "required": false - }, - { - "key": "TENANCY_BREAKER_THRESHOLD", - "type": "int", - "required": false - }, - { - "key": "TENANCY_BREAKER_COOLDOWN", - "type": "int", - "required": false - }, - { - "key": "TENANCY_BREAKER_WINDOW", - "type": "int", - "required": false - }, - { - "key": "TENANCY_TEMPLATE_PATH", - "type": "string", - "required": false - }, - { - "key": "TENANCY_TOKEN_TTL", - "type": "int", - "required": false - }, - { - "key": "TENANCY_CONTROL_PLANE", - "type": "bool", - "required": false - } - ] -} diff --git a/plugins/Tenancy/resources/views/hosts/index.php b/plugins/Tenancy/resources/views/hosts/index.php deleted file mode 100644 index 4bab494..0000000 --- a/plugins/Tenancy/resources/views/hosts/index.php +++ /dev/null @@ -1,178 +0,0 @@ - -
-

Add a custom domain

-

Register a hostname for the current tenant, publish the DNS - challenge we return, then verify it.

- - - - -
- - - -
- -
- -
- - - -
- -
-

Hosts

-

Loaded from GET /ajx/tenant/hosts.

- - - - - - - - -
HostnameStatusPrimaryVerified
Loading…
- -
- -
-
- - - - diff --git a/plugins/Tenancy/resources/views/layouts/app.php b/plugins/Tenancy/resources/views/layouts/app.php deleted file mode 100644 index f5576e0..0000000 --- a/plugins/Tenancy/resources/views/layouts/app.php +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - - - <?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?> · Tenancy - - - -
-

Tenancy

- -
- -
-
- -
- - - - diff --git a/plugins/Tenancy/resources/views/tenants/create.php b/plugins/Tenancy/resources/views/tenants/create.php deleted file mode 100644 index bf0dd9a..0000000 --- a/plugins/Tenancy/resources/views/tenants/create.php +++ /dev/null @@ -1,110 +0,0 @@ - -
-

Provision a new tenant

-

Creates the registry row, an isolated database + user, and runs the - template migrations. On any failure the partial work is rolled back.

- -
- - -
- - - -
- - - - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- -
- - Cancel -
-
-
- - diff --git a/plugins/Tenancy/resources/views/tenants/edit.php b/plugins/Tenancy/resources/views/tenants/edit.php deleted file mode 100644 index efb41ef..0000000 --- a/plugins/Tenancy/resources/views/tenants/edit.php +++ /dev/null @@ -1,97 +0,0 @@ - -
-

Edit tenant

-

Only the name, slug and status can be changed here. Database - coordinates are fixed once a tenant is provisioned.

- -
- - -
- - - -
- - - -
- - -
- -
- - Back -
-
-
- - diff --git a/plugins/Tenancy/resources/views/tenants/index.php b/plugins/Tenancy/resources/views/tenants/index.php deleted file mode 100644 index b3ae0e3..0000000 --- a/plugins/Tenancy/resources/views/tenants/index.php +++ /dev/null @@ -1,89 +0,0 @@ - -
-

Your tenants

-

Loaded from GET /ajx/me/tenants (requires the auth filter). - Select one to scope your session to that tenant.

- - - - - - - - -
NameSlugRoleStatus
Loading…
- -
- -
-
- - - - diff --git a/plugins/Tenancy/resources/views/tenants/manage.php b/plugins/Tenancy/resources/views/tenants/manage.php deleted file mode 100644 index 7425e6c..0000000 --- a/plugins/Tenancy/resources/views/tenants/manage.php +++ /dev/null @@ -1,96 +0,0 @@ - -
-
-

Tenants

- + New tenant -
-

Control plane for the whole fleet — backed by - GET /ajx/admin/tenants. Requires platform-admin access.

- - - - - - - - -
NameSlugDatabaseStatus
Loading…
- -
- -
-
- - - - diff --git a/plugins/Tenancy/ui/README.md b/plugins/Tenancy/ui/README.md deleted file mode 100644 index d76fa7f..0000000 --- a/plugins/Tenancy/ui/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# Tenancy plugin UI — admin + tenant pages - -The Tenancy plugin ships a Pageflow (React) UI with pages for **both faces**: - -``` -plugins/Tenancy/ui/ -├─ ui.json alias "@tenancy" + surfaces map { admin: admin/Pages, site: site/Pages } -├─ index.ts barrel — exposes shared bits + the API client as @tenancy -├─ lib/client.ts typed /ajx client (CSRF + envelope handling) -├─ components/ -│ ├─ TenantBadge.tsx shared tenant identity chip -│ └─ StatusBadge.tsx shared status pill (active/verified/pending/…) -├─ admin/Pages/Tenant/ ADMIN surface pages (platform-admin control plane) -│ ├─ Manage.tsx component "Tenant/Manage" (fleet list + delete) -│ ├─ Create.tsx component "Tenant/Create" (provision a tenant) -│ └─ Edit.tsx component "Tenant/Edit" (name/slug/status) -└─ site/Pages/Tenant/ TENANT surface pages - ├─ Index.tsx component "Tenant/Index" (tenant picker) - └─ Hosts.tsx component "Tenant/Hosts" (custom domains) -``` - -## How it reaches a project - -1. **Federation** — `hkm ui sync` mirrors this `ui/` into the project's - `frontend/plugins/tenancy/` and adds the `@tenancy` alias to - `tsconfig.plugins.json`. -2. **Per-face discovery** — each surface globs the plugin pages for its face, so - there is no per-page wiring: - - admin surface: `import.meta.glob("../../../plugins/*/admin/Pages/**/*.tsx")` - - site surface: `import.meta.glob("../../../plugins/*/site/Pages/**/*.tsx")` - Project pages are spread first, so a project can override a plugin page. -3. **Server** — the plugin's `TenantPageController` renders the component names: - `render($request, 'Tenant/Index'|'Tenant/Manage'|'Tenant/Create'|'Tenant/Edit'|'Tenant/Hosts', …)`. - Its page routes live in `module.json` with `requires: ["http.pageflow"]`. - -## Routes (module.json) - -| Method · Path | Face | Component | -|---|---|---| -| GET `/tenants` | site | `Tenant/Index` | -| GET `/tenant/hosts` | site | `Tenant/Hosts` | -| GET `/tenants/manage` | admin | `Tenant/Manage` | -| GET `/tenants/create` | admin | `Tenant/Create` | -| GET `/tenants/{tenantId}/edit` | admin | `Tenant/Edit` | - -`/tenants/manage|create|{id}/edit` render through the **admin** surface; the -tenant-facing `/tenants` and `/tenant/hosts` through the **site** surface. - -Every page here is private control-plane surface, so `TenantPageController` -passes the reserved `seoHead` prop built with `seoPrivate()` (branded title + -`noindex, nofollow`). Pages must NOT set `` — the client syncs the -tab title from `seoHead` on every navigation (see `plugins/Pageflow/README.md`). - -## Data flow - -The page shells carry no data props (except `Tenant/Edit`, which gets -`{ tenantId }`). Every page **hydrates over the JSON API** using the typed -`@tenancy` client (`lib/client.ts`), a TypeScript port of the plugin's original -vanilla `TenancyApp` client: - -- picker: `GET /ajx/me/tenants`, `POST /ajx/tenants/{id}/select` -- fleet: `GET|POST|PUT|DELETE /ajx/admin/tenants[/{id}]` (platform-admin) -- hosts: `GET|POST /ajx/tenant/hosts`, `POST …/{id}/verify|primary`, `DELETE …/{id}` - -Auth is same-site: the browser sends the session cookie automatically; unsafe -requests carry the kernel CSRF token (read from the `` -tag Pageflow embeds in the shell) in the `X-CSRF-Token` header. - -> The former server-rendered PHP views under `resources/views/` are superseded by -> these federated pages. They remain in the repo as a zero-JS-build fallback but -> are no longer wired — the page routes now render Pageflow components. diff --git a/plugins/Tenancy/ui/admin/Pages/Tenant/Create.tsx b/plugins/Tenancy/ui/admin/Pages/Tenant/Create.tsx deleted file mode 100644 index 37546c5..0000000 --- a/plugins/Tenancy/ui/admin/Pages/Tenant/Create.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import { useState } from "react"; -import { Head, Link, router } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Input } from "@ui/input"; -import { Label } from "@ui/label"; -import { Card, CardContent } from "@ui/card"; -import { Alert, AlertDescription } from "@ui/alert"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@ui/select"; -import { useTenancy } from "@tenancy"; - -// ADMIN page contributed by the Tenancy PLUGIN → component "Tenant/Create". -// Server: TenantPageController@create. Provisions a new tenant (registry row + -// isolated database) via POST /ajx/admin/tenants. Platform-admin only. -type Form = { - name: string; - slug: string; - driver: string; - db_name: string; - db_user: string; - db_password: string; - db_host: string; - db_port: string; -}; - -const DRIVERS = ["mysql", "pgsql", "sqlsrv"]; - -export default function TenantCreate() { - const api = useTenancy(); - const [form, setForm] = useState
({ - name: "", - slug: "", - driver: "mysql", - db_name: "", - db_user: "", - db_password: "", - db_host: "127.0.0.1", - db_port: "", - }); - const [errors, setErrors] = useState>({}); - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); - - const set = (key: keyof Form) => (e: React.ChangeEvent) => - setForm((f) => ({ ...f, [key]: e.target.value })); - - async function submit(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setError(null); - setErrors({}); - try { - await api.adminCreateTenant({ - name: form.name.trim(), - slug: form.slug.trim(), - driver: form.driver.trim(), - db_name: form.db_name.trim(), - db_user: form.db_user.trim(), - db_password: form.db_password, - db_host: form.db_host.trim(), - db_port: form.db_port ? parseInt(form.db_port, 10) : 0, - }); - router.visit("/tenants/manage"); - } catch (e) { - const err = e as { message: string; fields?: Record }; - setErrors(err.fields ?? {}); - setError(err.message); - } finally { - setSaving(false); - } - } - - return ( - <> - -
- -

New tenant

-

- Creates the registry row and provisions an isolated database. -

- - {error && ( - - {error} - - )} - - - - - - - - - - - - - -
- - - - - - -
- - - -
- - - - - - -
- -
- - -
- -
-
-
- - ); -} - -function Field({ - label, - htmlFor, - error, - children, -}: { - label: string; - htmlFor: string; - error?: string; - children: React.ReactNode; -}) { - return ( -
- - {children} - {error &&

{error}

} -
- ); -} diff --git a/plugins/Tenancy/ui/admin/Pages/Tenant/Edit.tsx b/plugins/Tenancy/ui/admin/Pages/Tenant/Edit.tsx deleted file mode 100644 index a371279..0000000 --- a/plugins/Tenancy/ui/admin/Pages/Tenant/Edit.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useEffect, useState } from "react"; -import { usePage, Head, Link, router } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Input } from "@ui/input"; -import { Label } from "@ui/label"; -import { Card, CardContent } from "@ui/card"; -import { Alert, AlertDescription } from "@ui/alert"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@ui/select"; -import { useTenancy, type TenantDetail } from "@tenancy"; - -// ADMIN page contributed by the Tenancy PLUGIN → component "Tenant/Edit". -// Server: TenantPageController@edit passes { tenantId }. Only name/slug/status -// are editable (PUT /ajx/admin/tenants/{id}); DB connection details are fixed at -// provisioning time. Platform-admin only. -type EditProps = { tenantId: string }; - -const STATUSES = ["active", "provisioning", "suspended", "deleted"]; - -export default function TenantEdit() { - const { props } = usePage(); - const { tenantId } = props; - const api = useTenancy(); - - const [tenant, setTenant] = useState(null); - const [name, setName] = useState(""); - const [slug, setSlug] = useState(""); - const [status, setStatus] = useState("active"); - const [errors, setErrors] = useState>({}); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - - useEffect(() => { - api - .adminTenant(tenantId) - .then((t) => { - setTenant(t); - setName(t.name); - setSlug(t.slug); - setStatus(t.status); - }) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); - }, [tenantId]); - - async function submit(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setError(null); - setErrors({}); - try { - await api.adminUpdateTenant(tenantId, { name: name.trim(), slug: slug.trim(), status }); - router.visit("/tenants/manage"); - } catch (e) { - const err = e as { message: string; fields?: Record }; - setErrors(err.fields ?? {}); - setError(err.message); - } finally { - setSaving(false); - } - } - - return ( - <> - -
- -

Edit tenant

-

- Only the name, slug and status can be changed. Database connection details are fixed. -

- - {error && ( - - {error} - - )} - - {loading ? ( -

Loading…

- ) : ( - - -
-
- - setName(e.target.value)} /> - {errors.name &&

{errors.name}

} -
-
- - setSlug(e.target.value)} /> - {errors.slug &&

{errors.slug}

} -
-
- - - {errors.status &&

{errors.status}

} -
- - {tenant && ( -
-
Database
-
- {tenant.dbDriver} · {tenant.dbName} @ {tenant.dbHost}:{tenant.dbPort} -
-
Schema
-
{tenant.schemaVersion ?? "—"}
-
- )} - -
- - -
-
-
-
- )} -
- - ); -} diff --git a/plugins/Tenancy/ui/admin/Pages/Tenant/Manage.tsx b/plugins/Tenancy/ui/admin/Pages/Tenant/Manage.tsx deleted file mode 100644 index d33b218..0000000 --- a/plugins/Tenancy/ui/admin/Pages/Tenant/Manage.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useEffect, useState } from "react"; -import { Head, Link } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Alert, AlertDescription } from "@ui/alert"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@ui/table"; -import { StatusBadge, useTenancy, type TenantDetail } from "@tenancy"; - -// ADMIN page contributed by the Tenancy PLUGIN → component "Tenant/Manage". -// Server: TenantPageController@manage. Platform-admin control plane for the whole -// fleet, backed by GET/DELETE /ajx/admin/tenants (requires platform-admin access). -export default function TenantManage() { - const api = useTenancy(); - const [tenants, setTenants] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - async function load() { - setLoading(true); - setError(null); - try { - setTenants((await api.adminTenants()) ?? []); - } catch (e) { - setError((e as Error).message); - } finally { - setLoading(false); - } - } - - useEffect(() => { - load(); - }, []); - - async function remove(t: TenantDetail) { - if (!window.confirm(`Delete tenant "${t.name}"? This drops its database user and registry row.`)) { - return; - } - const dropDatabase = window.confirm( - `Also DROP the tenant database "${t.dbName}"? All its data is lost. OK = drop, Cancel = keep.`, - ); - try { - await api.adminDeleteTenant(t.tenantId, dropDatabase); - await load(); - } catch (e) { - setError((e as Error).message); - } - } - - return ( - <> - -
-
-
-

Tenants

-

- Control plane for the whole fleet. Requires platform-admin access. -

-
- -
- - {error && ( - - {error} - - )} - - {loading ? ( -

Loading…

- ) : tenants.length === 0 ? ( -

No tenants yet. Create the first one.

- ) : ( - - - - Name - Slug - Database - Status - Actions - - - - {tenants.map((t) => ( - - {t.name} - {t.slug} - - {t.dbDriver} · {t.dbName} @ {t.dbHost}:{t.dbPort} - - - - - - - - - - ))} - -
- )} -
- - ); -} diff --git a/plugins/Tenancy/ui/components/StatusBadge.tsx b/plugins/Tenancy/ui/components/StatusBadge.tsx deleted file mode 100644 index b0b0156..0000000 --- a/plugins/Tenancy/ui/components/StatusBadge.tsx +++ /dev/null @@ -1,27 +0,0 @@ -// A status pill built on the shared shadcn . It maps a tenant / host -// status string to a consistent tone so the whole tenancy UI reads the same way. -import { Badge } from "@ui/badge"; -import { cn } from "@lib/utils"; - -// Semantic tones layered over the shadcn Badge (which ships default/secondary/ -// destructive/outline). We use `outline` as the base and tint via className so -// success/warning stay on-brand with the design tokens. -const TONE: Record = { - active: "border-transparent bg-green-100 text-green-700", - verified: "border-transparent bg-green-100 text-green-700", - primary: "border-transparent bg-indigo-100 text-indigo-700", - provisioning: "border-transparent bg-amber-100 text-amber-700", - pending: "border-transparent bg-amber-100 text-amber-700", - suspended: "border-transparent bg-red-100 text-red-700", - failed: "border-transparent bg-red-100 text-red-700", - deleted: "border-transparent bg-red-100 text-red-700", -}; - -export function StatusBadge({ status }: { status: string }) { - const tone = TONE[status.toLowerCase()]; - return ( - - {status} - - ); -} diff --git a/plugins/Tenancy/ui/components/TenantBadge.tsx b/plugins/Tenancy/ui/components/TenantBadge.tsx deleted file mode 100644 index 19ad426..0000000 --- a/plugins/Tenancy/ui/components/TenantBadge.tsx +++ /dev/null @@ -1,21 +0,0 @@ -// A shared tenant identity chip built on the shared shadcn . Shows the -// tenant-name initials plus the name and slug. Consumers import it as `@tenancy`. -import { Avatar, AvatarFallback } from "@ui/avatar"; -import type { TenantSummary } from "../lib/client"; - -export function TenantBadge({ tenant }: { tenant: Pick }) { - const initials = tenant.name.slice(0, 2).toUpperCase(); - return ( - - - - {initials} - - - - {tenant.name} - {tenant.slug} - - - ); -} diff --git a/plugins/Tenancy/ui/index.ts b/plugins/Tenancy/ui/index.ts deleted file mode 100644 index dbddc6e..0000000 --- a/plugins/Tenancy/ui/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Public barrel for the Tenancy plugin's UI — reachable from any surface as -// `@tenancy` / `@tenancy/lib/client` once `hkm ui sync` has federated it. -// The plugin exposes shared building blocks + a typed API client here; its PAGES -// live under admin/Pages (platform-admin surface) and site/Pages (tenant surface). -export { TenantBadge } from "./components/TenantBadge"; -export { StatusBadge } from "./components/StatusBadge"; -export { useTenancy, tenancyClient } from "./lib/client"; -export type { - TenantSummary, - TenantDetail, - TenantHost, - HostInstructions, - HostVerification, -} from "./lib/client"; diff --git a/plugins/Tenancy/ui/lib/client.ts b/plugins/Tenancy/ui/lib/client.ts deleted file mode 100644 index 2cc54a8..0000000 --- a/plugins/Tenancy/ui/lib/client.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Typed API client for the Tenancy plugin's /ajx endpoints — a TypeScript port -// of the vanilla `TenancyApp` client that the plugin's server views used. -// -// Same-site auth: the browser sends the session cookie automatically -// (credentials:"same-origin"), so there is no bearer token. Every UNSAFE request -// (POST/PUT/PATCH/DELETE) carries the CSRF token — read from the tag that -// Pageflow embeds in the HTML shell — in the X-CSRF-Token header, so the kernel -// SecurityGateway accepts the mutation. - -const API_BASE = "/ajx"; -const SAFE: Record = { GET: 1, HEAD: 1, OPTIONS: 1 }; - -export interface TenantSummary { - tenantId: string; - name: string; - slug: string; - role: string; - status: string; -} - -export interface TenantDetail { - tenantId: string; - name: string; - slug: string; - dbDriver: string; - dbHost: string; - dbPort: number; - dbName: string; - dbUsername: string; - status: string; - schemaVersion: string | null; -} - -export interface TenantHost { - host_id: number; - tenant_id: string; - hostname: string; - ip_address: string | null; - status: string; - verification_token: string; - is_primary: boolean; - verified_at: string | null; - created_at: string; - updated_at: string; -} - -export interface HostInstructions { - hostname: string; - dns_record: { type: string; name: string; value: string; ttl: number }; - expected_ip: string | null; - instructions: string; -} - -export interface HostVerification { - hostname: string; - verified: boolean; - status: string; - reason: string | null; - found: { txt: string[]; ips: string[] }; -} - -/** An API error that carries the HTTP status + per-field validation messages. */ -export class TenancyApiError extends Error { - status: number; - fields: Record; - constructor(message: string, status: number, fields: Record = {}) { - super(message); - this.name = "TenancyApiError"; - this.status = status; - this.fields = fields; - } -} - -function csrfToken(): string { - const m = document.querySelector('meta[name="csrf-token"]'); - return m?.getAttribute("content") ?? ""; -} - -function buildHeaders(method: string, hasBody: boolean): HeadersInit { - const h: Record = { - Accept: "application/json", - "X-Requested-With": "XMLHttpRequest", - }; - if (hasBody) h["Content-Type"] = "application/json"; - if (!SAFE[method]) h["X-CSRF-Token"] = csrfToken(); - return h; -} - -async function request(method: string, path: string, body?: unknown): Promise { - const res = await fetch(API_BASE + path, { - method, - headers: buildHeaders(method, body !== undefined), - body: body !== undefined ? JSON.stringify(body) : undefined, - credentials: "same-origin", - }); - - const text = await res.text(); - const parsed = text ? JSON.parse(text) : null; - - if (!res.ok) { - const err = parsed?.error ?? {}; - throw new TenancyApiError(err.message ?? `HTTP ${res.status}`, res.status, err.fields ?? {}); - } - - // Endpoints wrap payloads in a { data: … } envelope. Unwrap it here so callers - // work with the value directly; tolerant when a bare value is returned. - return (parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed) as T; -} - -export const tenancyClient = { - // Tenant picker (any authenticated user). - myTenants: () => request("GET", "/me/tenants"), - selectTenant: (id: string) => - request<{ token: string; tokenType: string; tenantId: string; role: string; expiresIn: number }>( - "POST", - `/tenants/${encodeURIComponent(id)}/select`, - ), - - // Tenant administration (platform-admin only). - adminTenants: () => request("GET", "/admin/tenants"), - adminTenant: (id: string) => request("GET", `/admin/tenants/${encodeURIComponent(id)}`), - adminCreateTenant: (payload: Record) => - request("POST", "/admin/tenants", payload), - adminUpdateTenant: (id: string, payload: Record) => - request("PUT", `/admin/tenants/${encodeURIComponent(id)}`, payload), - adminDeleteTenant: (id: string, dropDatabase: boolean) => - request("DELETE", `/admin/tenants/${encodeURIComponent(id)}`, { drop_database: dropDatabase }), - - // Invitations. - acceptInvitation: (token: string) => request<{ tenantId: string }>("POST", "/invitations/accept", { token }), - - // Custom hosts for the currently-scoped tenant. - hosts: () => request("GET", "/tenant/hosts"), - addHost: (payload: { hostname: string; ip_address?: string | null }) => - request("POST", "/tenant/hosts", payload), - hostInstructions: (id: number) => - request("GET", `/tenant/hosts/${id}/instructions`), - verifyHost: (id: number) => request("POST", `/tenant/hosts/${id}/verify`), - makeHostPrimary: (id: number) => request("POST", `/tenant/hosts/${id}/primary`), - removeHost: (id: number) => request("DELETE", `/tenant/hosts/${id}`), -}; - -/** Hook alias so pages can `const api = useTenancy()`. The client is stateless. */ -export function useTenancy() { - return tenancyClient; -} diff --git a/plugins/Tenancy/ui/site/Pages/Tenant/Hosts.tsx b/plugins/Tenancy/ui/site/Pages/Tenant/Hosts.tsx deleted file mode 100644 index c1d5953..0000000 --- a/plugins/Tenancy/ui/site/Pages/Tenant/Hosts.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { useEffect, useState } from "react"; -import { Head, Link } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Input } from "@ui/input"; -import { Label } from "@ui/label"; -import { Badge } from "@ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@ui/card"; -import { Alert, AlertDescription } from "@ui/alert"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@ui/table"; -import { - StatusBadge, - useTenancy, - type HostInstructions, - type TenantHost, -} from "@tenancy"; - -// SITE page contributed by the Tenancy PLUGIN → component "Tenant/Hosts". -// Server: TenantPageController@hosts. Manages the custom domains of the tenant -// the caller is currently scoped to, over the /ajx/tenant/hosts endpoints. -export default function TenantHosts() { - const api = useTenancy(); - const [hosts, setHosts] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - - const [hostname, setHostname] = useState(""); - const [ip, setIp] = useState(""); - const [adding, setAdding] = useState(false); - const [instructions, setInstructions] = useState(null); - - async function load() { - setLoading(true); - try { - setHosts((await api.hosts()) ?? []); - } catch (e) { - setError((e as Error).message); - } finally { - setLoading(false); - } - } - - useEffect(() => { - load(); - }, []); - - async function add(e: React.FormEvent) { - e.preventDefault(); - setAdding(true); - setError(null); - setNotice(null); - try { - const res = await api.addHost({ hostname: hostname.trim(), ip_address: ip.trim() || null }); - setInstructions(res); - setHostname(""); - setIp(""); - await load(); - } catch (e) { - setError((e as Error).message); - } finally { - setAdding(false); - } - } - - async function verify(host: TenantHost) { - setError(null); - setNotice(null); - try { - const res = await api.verifyHost(host.host_id); - setNotice( - res.verified - ? `${res.hostname} verified.` - : `${res.hostname} not verified yet${res.reason ? ` — ${res.reason}` : ""}.`, - ); - await load(); - } catch (e) { - setError((e as Error).message); - } - } - - async function makePrimary(host: TenantHost) { - try { - await api.makeHostPrimary(host.host_id); - await load(); - } catch (e) { - setError((e as Error).message); - } - } - - async function remove(host: TenantHost) { - if (!window.confirm(`Stop routing ${host.hostname}?`)) return; - try { - await api.removeHost(host.host_id); - await load(); - } catch (e) { - setError((e as Error).message); - } - } - - return ( - <> - -
-
-

Custom domains

- -
- - {error && ( - - {error} - - )} - {notice && ( - - {notice} - - )} - - - - Add a domain - - -
-
- - setHostname(e.target.value)} - /> -
-
- - setIp(e.target.value)} - /> -
- -
-
-
- - {instructions && ( - - -

Publish this DNS record, then verify:

-
-                {instructions.dns_record.type}  {instructions.dns_record.name}  {instructions.dns_record.value}
-              
-

{instructions.instructions}

-
-
- )} - - {loading ? ( -

Loading…

- ) : hosts.length === 0 ? ( -

No custom domains yet.

- ) : ( - - - - Hostname - Status - Actions - - - - {hosts.map((h) => ( - - - {h.hostname} - {h.is_primary && ( - - primary - - )} - - - - - - - {!h.is_primary && ( - - )} - - - - ))} - -
- )} -
- - ); -} diff --git a/plugins/Tenancy/ui/site/Pages/Tenant/Index.tsx b/plugins/Tenancy/ui/site/Pages/Tenant/Index.tsx deleted file mode 100644 index e53dd7f..0000000 --- a/plugins/Tenancy/ui/site/Pages/Tenant/Index.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useState } from "react"; -import { Head, Link, router } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Card, CardContent } from "@ui/card"; -import { Alert, AlertDescription } from "@ui/alert"; -import { Skeleton } from "@ui/skeleton"; -import { StatusBadge, TenantBadge, useTenancy, type TenantSummary } from "@tenancy"; - -// SITE page contributed by the Tenancy PLUGIN → component "Tenant/Index". -// Server: TenantPageController@index. It hydrates over GET /ajx/me/tenants and -// re-mints a tenant-scoped token via POST /ajx/tenants/{id}/select. -export default function TenantIndex() { - const api = useTenancy(); - const [tenants, setTenants] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selecting, setSelecting] = useState(null); - - useEffect(() => { - api - .myTenants() - .then((rows) => setTenants(rows ?? [])) - .catch((e) => setError(e.message)) - .finally(() => setLoading(false)); - }, []); - - async function select(id: string) { - setSelecting(id); - setError(null); - try { - await api.selectTenant(id); - // A fresh tenant-scoped session cookie is now set; reload into the tenant. - router.reload(); - } catch (e) { - setError((e as Error).message); - setSelecting(null); - } - } - - return ( - <> - -
-
-
-

Your tenants

-

Pick a workspace to continue.

-
- -
- - {error && ( - - {error} - - )} - - {loading ? ( -
- - -
- ) : tenants.length === 0 ? ( -

- You are not a member of any tenant yet. Accept an invitation to get started. -

- ) : ( -
- {tenants.map((t) => ( - - -
- - {t.role} - -
- -
-
- ))} -
- )} -
- - ); -} diff --git a/plugins/Tenancy/ui/ui.json b/plugins/Tenancy/ui/ui.json deleted file mode 100644 index bd62277..0000000 --- a/plugins/Tenancy/ui/ui.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "alias": "@tenancy", - "entry": "index.ts", - "framework": "react", - "surfaces": { - "admin": "admin/Pages", - "site": "site/Pages" - }, - "dependencies": {} -} diff --git a/plugins/User/API/Contracts/.gitkeep b/plugins/User/API/Contracts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/User/API/Contracts/TenantProfileReaderContract.php b/plugins/User/API/Contracts/TenantProfileReaderContract.php deleted file mode 100644 index 915f7d3..0000000 --- a/plugins/User/API/Contracts/TenantProfileReaderContract.php +++ /dev/null @@ -1,32 +0,0 @@ -email is set ONLY for the matched cases (expired / already verified), so - * the caller can bind a resend cookie to that address. - */ - public function verifyEmailByToken(string $token): VerifyEmailResult; - - /** - * PUBLIC (unauthenticated) re-issue of an email-verification token. Re-arms a - * fresh token for an UNVERIFIED account and returns the plaintext for the - * caller to email. Returns null when there is nothing to send (unknown email - * OR already verified) — the caller MUST respond identically either way so a - * request never reveals whether an address is registered or its state. - */ - public function resendVerification(string $email): ?string; - - public function find(string $id, bool $checkMembership = false, bool $isAuth = false): ?UserDTO; - - /** Look up a user by username OR email (no credential check). Null if absent. */ - public function findByIdentifier(string $identifier, bool $checkMembership = false): ?UserDTO; - - /** - * Force-set a user's password (password-reset flow — token-authorized, so it - * bypasses the self/permission gate). Also clears remember tokens so existing - * "remember me" cookies die. Returns false if no such user. - */ - public function resetPassword(string $userId, string $newPassword): bool; - - /** Apply a partial update. Returns null if no such (non-deleted) user. */ - public function update(string $id, UpdateUserDTO $dto): ?UserDTO; - - /** Confirm a user's email from a verification token. Null if no such user. */ - public function verifyEmail(string $id, VerifyEmailDTO $dto): ?UserDTO; - - /** - * Verify a plaintext credential for a username/email. Returns the user on - * success, null on any failure (unknown user, wrong password, inactive, - * or temporarily locked out). Timing-safe and rate-limited. - */ - public function verifyCredentials(string $identifier, string $password): ?UserDTO; - - /** - * Resolve a user from a plaintext "remember me" token (the second segment of - * a recaller cookie). Returns null on any miss so a forged/stale token can - * never authenticate. Timing-safe: the token is matched by its stored hash. - */ - public function findByRememberToken(string $token): ?UserDTO; - - /** - * Issue a fresh remember-token for a user, persist its hash, and return the - * PLAINTEXT once (goes into the recaller cookie). Rotating on every use means - * a stolen cookie is invalidated the moment the real user next authenticates. - */ - public function cycleRememberToken(string $userId, bool $checkMembership = false): string; - - /** Clear a user's remember-token (logout) so outstanding recaller cookies die. */ - public function clearRememberToken(string $userId, bool $checkMembership = false): void; - - public function delete(string $id, bool $checkMembership = false): bool; -} diff --git a/plugins/User/API/DTOs/FeedbackPage.php b/plugins/User/API/DTOs/FeedbackPage.php deleted file mode 100644 index f539bb2..0000000 --- a/plugins/User/API/DTOs/FeedbackPage.php +++ /dev/null @@ -1,40 +0,0 @@ - $items */ - public function __construct( - public array $items, - public bool $hasMore, - public int $limit, - ) {} - - /** The cursor to pass as ?after= for the next page (null on the last page). */ - public function nextCursor(): ?string - { - if (!$this->hasMore || $this->items === []) { - return null; - } - return $this->items[array_key_last($this->items)]->id()->value(); - } - - /** @return array */ - public function meta(): array - { - return [ - 'count' => count($this->items), - 'limit' => $this->limit, - 'has_more' => $this->hasMore, - 'next_cursor' => $this->nextCursor(), - ]; - } -} diff --git a/plugins/User/API/DTOs/ListFeedbackQuery.php b/plugins/User/API/DTOs/ListFeedbackQuery.php deleted file mode 100644 index 3d7ef73..0000000 --- a/plugins/User/API/DTOs/ListFeedbackQuery.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * `after` is the opaque public feedback_id of the last row from the previous - * page; the repository resolves it to the internal sort key. `status` is an - * optional triage filter, validated against the closed enum. - */ -final readonly class ListFeedbackQuery -{ - public const DEFAULT_LIMIT = 25; - public const MAX_LIMIT = 100; - - public function __construct( - public int $limit, - public ?string $after, - public ?FeedbackStatus $status, - ) {} - - public static function fromRequest(Request $request): self - { - $limit = (int) $request->input('limit', self::DEFAULT_LIMIT); - $limit = max(1, min($limit, self::MAX_LIMIT)); - - $after = trim((string) $request->input('after', '')); - // Cursor must look like a UUID; otherwise ignore it (start from the top). - if ($after === '' || !preg_match('/^[0-9a-fA-F-]{36}$/', $after)) { - $after = null; - } - - // Unknown status → ignore the filter rather than 422 a read-only list. - $status = FeedbackStatus::tryFrom(trim((string) $request->input('status', ''))); - - return new self(limit: $limit, after: $after, status: $status); - } -} diff --git a/plugins/User/API/DTOs/ListUsersQuery.php b/plugins/User/API/DTOs/ListUsersQuery.php deleted file mode 100644 index 8f299a5..0000000 --- a/plugins/User/API/DTOs/ListUsersQuery.php +++ /dev/null @@ -1,41 +0,0 @@ - - * - * Keyset (cursor) pagination is O(1) at any depth — unlike OFFSET, which scans - * and skips. `after` is the opaque user_id of the last row from the previous - * page (results are ordered by user_id DESC, which is time-ordered via ULID). - */ -final readonly class ListUsersQuery -{ - public const DEFAULT_LIMIT = 25; - public const MAX_LIMIT = 100; - - public function __construct( - public int $limit, - public ?string $after, - ) {} - - public static function fromRequest(Request $request): self - { - $limit = (int) $request->input('limit', self::DEFAULT_LIMIT); - $limit = max(1, min($limit, self::MAX_LIMIT)); - - $after = trim((string) $request->input('after', '')); - // user_id is Crockford base32 ULID — reject anything else as a cursor. - if ($after === '' || !preg_match('/^[0-9A-HJKMNP-TV-Z]{1,31}$/', $after)) { - $after = null; - } - - return new self(limit: $limit, after: $after); - } -} diff --git a/plugins/User/API/DTOs/RegisterUserDTO.php b/plugins/User/API/DTOs/RegisterUserDTO.php deleted file mode 100644 index c553e76..0000000 --- a/plugins/User/API/DTOs/RegisterUserDTO.php +++ /dev/null @@ -1,153 +0,0 @@ - - */ - public array $profile = [], - ) { - } - - /** Profile keys accepted at signup — never trust arbitrary request input. */ - private const PROFILE_FIELDS = [ - 'first_name' => 80, - 'last_name' => 80, - 'phone' => 15, - 'timezone' => 50, - 'locale' => 5, - ]; - - protected static function rules(): array - { - return [ - 'username' => 'required|string|min:5|max:50|regex:/^[A-Za-z0-9._-]+$/', - 'email' => 'required|string|email|max:150', - 'password' => 'required|string', - ]; - } - - protected static function messages(): array - { - return [ - 'username.min' => 'Username must be between 5 and 50 characters.', - 'username.max' => 'Username must be between 5 and 50 characters.', - 'username.regex' => 'Username may only contain letters, digits, dot, underscore and hyphen.', - 'email.email' => 'Email is not a valid address.', - 'email.max' => 'Email must be 150 characters or fewer.', - ]; - } - - /** - * Validation for the OPTIONAL profile fields. Every rule is `nullable`, so - * an absent field passes; a present one must match. Uses only CORE Validator - * rules (no CommonRules registration needed). Keys mirror PROFILE_FIELDS. - * - * @return array - */ - private static function profileRules(): array - { - return [ - 'first_name' => "nullable|string|max:80|regex:/^[\\p{L}\\p{M} .,'\\-]+$/u", - 'last_name' => "nullable|string|max:80|regex:/^[\\p{L}\\p{M} .,'\\-]+$/u", - 'phone' => 'nullable|string|max:15|regex:/^[0-9+()\\s-]+$/', - 'timezone' => 'nullable|string|timezone', - 'locale' => 'nullable|string|max:5|regex:/^[A-Za-z]{2}([_-][A-Za-z]{2})?$/', - ]; - } - - public static function fromRequest(Request $request): self - { - // Shape errors + password-strength errors + profile-field errors all - // combine into one 422. Profile is validated on the ASSEMBLED array so a - // derived full_name → first_name/last_name split is checked too. - $profile = self::profileFrom($request); - - $errors = static::collectErrors($request->all()); - $errors += PasswordPolicy::validate((string) $request->input('password', '')); - $errors += Validator::make($profile, self::profileRules())->errors(); - if ($errors !== []) { - throw new ValidationException($errors); - } - - return new self( - username: Username::fromString(trim((string) $request->input('username', ''))), - email: Email::fromString(trim((string) $request->input('email', ''))), - password: (string) $request->input('password', ''), - tenantId: (string) ($request->attribute('tenant') ?? ''), - profile: $profile, - ); - } - - /** @return array only the non-empty, clipped profile fields. */ - private static function profileFrom(Request $request): array - { - $profile = []; - foreach (self::PROFILE_FIELDS as $key => $max) { - $value = trim((string) $request->input($key, '')); - if ($value !== '') { - $profile[$key] = mb_substr($value, 0, $max); - } - } - - // Some clients send a single "full name" instead of first/last. Split it - // (first token → first_name, remainder → last_name) to fill only the - // parts not already supplied explicitly — explicit fields always win. - $full = trim((string) $request->input( - 'full_name', - (string) $request->input( - 'fullname', - (string) $request->input('name', '') - ) - )); - if ($full !== '') { - $parts = preg_split('/\s+/', $full, 2) ?: []; - $first = trim($parts[0] ?? ''); - $last = trim($parts[1] ?? ''); - if ($first !== '' && !isset($profile['first_name'])) { - $profile['first_name'] = mb_substr($first, 0, self::PROFILE_FIELDS['first_name']); - } - if ($last !== '' && !isset($profile['last_name'])) { - $profile['last_name'] = mb_substr($last, 0, self::PROFILE_FIELDS['last_name']); - } - } - - return $profile; - } -} diff --git a/plugins/User/API/DTOs/SubmitFeedbackDTO.php b/plugins/User/API/DTOs/SubmitFeedbackDTO.php deleted file mode 100644 index f801d2b..0000000 --- a/plugins/User/API/DTOs/SubmitFeedbackDTO.php +++ /dev/null @@ -1,60 +0,0 @@ -input('category')); - } catch (\DomainException $e) { - $errors['category'] = $e->getMessage(); - } - - $rating = null; - try { - $rating = FeedbackRating::fromNullable($request->input('rating')); - } catch (\DomainException $e) { - $errors['rating'] = $e->getMessage(); - } - - $message = null; - try { - $message = FeedbackMessage::fromString((string) $request->input('message', '')); - } catch (\DomainException $e) { - $errors['message'] = $e->getMessage(); - } - - if ($errors !== []) { - throw new ValidationException($errors); - } - - /** @var FeedbackMessage $message */ - return new self(category: $category, rating: $rating, message: $message); - } -} diff --git a/plugins/User/API/DTOs/UpdateNotificationPreferencesDTO.php b/plugins/User/API/DTOs/UpdateNotificationPreferencesDTO.php deleted file mode 100644 index af77ed2..0000000 --- a/plugins/User/API/DTOs/UpdateNotificationPreferencesDTO.php +++ /dev/null @@ -1,70 +0,0 @@ - $flags */ - public function __construct( - public array $flags, - ) {} - - protected static function rules(): array - { - // Only the envelope is shape-validated; the per-flag mapping below is - // business logic (present-key extraction), not validation. - return ['flags' => 'nullable|array']; - } - - protected static function messages(): array - { - return ['flags.array' => 'flags must be an object of channel → topic booleans.']; - } - - public static function fromRequest(Request $request): self - { - static::validated($request); - - $provided = []; - $nested = $request->input('flags'); - - foreach (array_keys(UserNotificationPreferences::FLAG_DEFAULTS) as $key) { - [$channel, $topic] = explode('_', $key, 2); - - if (is_array($nested)) { - if (isset($nested[$channel]) && is_array($nested[$channel]) - && array_key_exists($topic, $nested[$channel])) { - $provided[$key] = self::toBool($nested[$channel][$topic]); - } - continue; - } - - // Flat fallback: only forward keys the client actually sent. - if ($request->has($key)) { - $provided[$key] = $request->boolean($key); - } - } - - return new self($provided); - } - - private static function toBool(mixed $value): bool - { - return filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? (bool) $value; - } -} diff --git a/plugins/User/API/DTOs/UpdatePreferencesDTO.php b/plugins/User/API/DTOs/UpdatePreferencesDTO.php deleted file mode 100644 index 9b939ff..0000000 --- a/plugins/User/API/DTOs/UpdatePreferencesDTO.php +++ /dev/null @@ -1,68 +0,0 @@ - 'nullable|regex:/^[a-zA-Z]{2,10}(-[a-zA-Z]{2,10})?$/', - 'currency' => 'nullable|regex:/^[a-zA-Z]{3}$/', - 'theme' => 'nullable|enum:' . Theme::class, - ]; - } - - protected static function messages(): array - { - return [ - 'language.regex' => 'Language must be a 2–10 letter tag, e.g. en or en-GB.', - 'currency.regex' => 'Currency must be a 3-letter ISO 4217 code, e.g. UGX.', - 'theme.enum' => 'Theme must be one of: light, dark, system.', - ]; - } - - public static function fromRequest(Request $request): self - { - static::validated($request); - - return new self( - language: self::trimOrNull($request->input('language')), - currency: self::trimOrNull($request->input('currency')), - theme: Theme::fromString((string) $request->input('theme', 'system')), - reduceMotion: $request->boolean('reduceMotion'), - largerText: $request->boolean('largerText'), - highContrast: $request->boolean('highContrast'), - screenReaderHints: $request->boolean('screenReaderHints'), - ); - } - - private static function trimOrNull(mixed $value): ?string - { - if ($value === null) { - return null; - } - $value = trim((string) $value); - return $value === '' ? null : $value; - } -} diff --git a/plugins/User/API/DTOs/UpdatePrivacyDTO.php b/plugins/User/API/DTOs/UpdatePrivacyDTO.php deleted file mode 100644 index 50fedc5..0000000 --- a/plugins/User/API/DTOs/UpdatePrivacyDTO.php +++ /dev/null @@ -1,47 +0,0 @@ - 'nullable|enum:' . ProfileVisibility::class]; - } - - protected static function messages(): array - { - return ['profileVisibility.enum' => 'Profile visibility must be one of: public, private, contacts.']; - } - - public static function fromRequest(Request $request): self - { - static::validated($request); - - return new self( - profileVisibility: ProfileVisibility::fromString((string) $request->input('profileVisibility', 'public')), - showPhone: $request->boolean('showPhone'), - showEmail: $request->boolean('showEmail'), - marketingOptIn: $request->boolean('marketingOptIn'), - analyticsOptIn: $request->boolean('analyticsOptIn'), - ); - } -} diff --git a/plugins/User/API/DTOs/UpdateProfileDTO.php b/plugins/User/API/DTOs/UpdateProfileDTO.php deleted file mode 100644 index 3b06326..0000000 --- a/plugins/User/API/DTOs/UpdateProfileDTO.php +++ /dev/null @@ -1,70 +0,0 @@ - 'nullable|string|max:80', - 'lastName' => 'nullable|string|max:80', - 'avatarUrl' => 'nullable|http_url|max:500', - 'timezone' => 'nullable|timezone', - 'locale' => 'nullable|regex:/^[a-z]{2}_[A-Z]{2}$/', - 'phone' => 'nullable|regex:/^\+?[0-9]{7,15}$/', - ]; - } - - protected static function messages(): array - { - return [ - 'locale.regex' => 'Locale must be in ll_CC form, e.g. en_US.', - 'phone.regex' => 'Phone must be 7–15 digits (optional leading +).', - ]; - } - - public static function fromRequest(Request $request): self - { - static::validated($request); // throws 422 on bad shape - - return new self( - firstName: self::trimOrNull($request->input('firstName')), - lastName: self::trimOrNull($request->input('lastName')), - avatarUrl: self::trimOrNull($request->input('avatarUrl')), - timezone: self::trimOrNull($request->input('timezone')), - locale: self::trimOrNull($request->input('locale')), - phone: self::trimOrNull($request->input('phone')), - ); - } - - private static function trimOrNull(mixed $value): ?string - { - if ($value === null) { - return null; - } - $value = trim((string) $value); - return $value === '' ? null : $value; - } -} diff --git a/plugins/User/API/DTOs/UpdateUserDTO.php b/plugins/User/API/DTOs/UpdateUserDTO.php deleted file mode 100644 index c9ad588..0000000 --- a/plugins/User/API/DTOs/UpdateUserDTO.php +++ /dev/null @@ -1,80 +0,0 @@ - 'string|min:5|max:50|regex:/^[A-Za-z0-9._-]+$/', - 'email' => 'string|email|max:150', - 'password' => 'string', - ]; - } - - protected static function messages(): array - { - return [ - 'username.min' => 'Username must be between 5 and 50 characters.', - 'username.max' => 'Username must be between 5 and 50 characters.', - 'username.regex' => 'Username may only contain letters, digits, dot, underscore and hyphen.', - 'email.email' => 'Email is not a valid address.', - 'email.max' => 'Email must be 150 characters or fewer.', - ]; - } - - public static function fromRequest(Request $request): self - { - $errors = static::collectErrors($request->all()); - if ($request->filled('password')) { - $errors += PasswordPolicy::validate((string) $request->input('password', '')); - } - if ($errors !== []) { - throw new ValidationException($errors); - } - - $username = $request->filled('username') - ? Username::fromString(trim((string) $request->input('username', ''))) - : null; - $email = $request->filled('email') - ? Email::fromString(trim((string) $request->input('email', ''))) - : null; - $password = $request->filled('password') ? (string) $request->input('password', '') : null; - - if ($username === null && $email === null && $password === null) { - throw new ValidationException(['_' => 'Provide at least one of: username, email, password.']); - } - - return new self(username: $username, email: $email, password: $password); - } - - public function hasChanges(): bool - { - return $this->username !== null || $this->email !== null || $this->password !== null; - } -} diff --git a/plugins/User/API/DTOs/UserDTO.php b/plugins/User/API/DTOs/UserDTO.php deleted file mode 100644 index 309192a..0000000 --- a/plugins/User/API/DTOs/UserDTO.php +++ /dev/null @@ -1,71 +0,0 @@ -getMembership()?->role; - $fullName = $user->getProfile()?->fullName(); - return new self( - id: $user->id(), - username: $user->username(), - email: $user->email(), - fullName: $fullName ?? '', - avatarUrl: $user->getProfile()?->avatarUrl(), - emailVerified: $user->isEmailVerified(), - roles: $roles !== null ? [$roles] : [], - joinedAt: $user->getMembership()?->joinedAt, - tenantId: $user->getMembership()?->tenantId, - createdAt: $user->createdAt()->format(\DateTimeInterface::RFC3339), - ); - } - - /** @return array */ - public function toArray(): array - { - return [ - 'id' => $this->id, - 'username' => $this->username, - 'email' => $this->email, - 'fullName' => $this->fullName, - 'emailVerified' => $this->emailVerified, - 'createdAt' => $this->createdAt, - 'avatarUrl' => $this->avatarUrl, - 'roles' => $this->roles, - 'permissions' => $this->permissions, - 'joinedAt' => $this->joinedAt, - 'tenantId' => $this->tenantId, - ]; - } -} diff --git a/plugins/User/API/DTOs/UserPage.php b/plugins/User/API/DTOs/UserPage.php deleted file mode 100644 index 7e510d7..0000000 --- a/plugins/User/API/DTOs/UserPage.php +++ /dev/null @@ -1,38 +0,0 @@ - $items */ - public function __construct( - public array $items, - public bool $hasMore, - public int $limit, - ) {} - - /** The cursor to pass as ?after= for the next page (null on the last page). */ - public function nextCursor(): ?string - { - if (!$this->hasMore || $this->items === []) { - return null; - } - return $this->items[array_key_last($this->items)]->id; - } - - /** @return array */ - public function meta(): array - { - return [ - 'count' => count($this->items), - 'limit' => $this->limit, - 'has_more' => $this->hasMore, - 'next_cursor' => $this->nextCursor(), - ]; - } -} diff --git a/plugins/User/API/DTOs/VerifyEmailDTO.php b/plugins/User/API/DTOs/VerifyEmailDTO.php deleted file mode 100644 index d4579ce..0000000 --- a/plugins/User/API/DTOs/VerifyEmailDTO.php +++ /dev/null @@ -1,36 +0,0 @@ - 'required|string']; - } - - protected static function messages(): array - { - return ['token.required' => 'A verification token is required.']; - } - - public static function fromRequest(Request $request): self - { - static::validated($request); - - return new self(token: trim((string) $request->input('token', ''))); - } -} diff --git a/plugins/User/API/DTOs/VerifyEmailResult.php b/plugins/User/API/DTOs/VerifyEmailResult.php deleted file mode 100644 index 56659d5..0000000 --- a/plugins/User/API/DTOs/VerifyEmailResult.php +++ /dev/null @@ -1,29 +0,0 @@ -version = '1.0'; - } - - public function name(): string - { - return 'feedback.submitted'; - } - - public function version(): string - { - return $this->version; - } - - /** @return array */ - public function payload(): array - { - return [ - 'feedbackId' => $this->feedbackId, - 'userId' => $this->userId, - 'category' => $this->category, - 'rating' => $this->rating, - 'occurredAt' => $this->occurredAt, - 'version' => $this->version, - ]; - } -} diff --git a/plugins/User/API/IntegrationEvents/GenericIntegrationEvent.php b/plugins/User/API/IntegrationEvents/GenericIntegrationEvent.php deleted file mode 100644 index e68428d..0000000 --- a/plugins/User/API/IntegrationEvents/GenericIntegrationEvent.php +++ /dev/null @@ -1,30 +0,0 @@ - $payload */ - public function __construct( - private string $name, - private string $version, - private array $payload, - ) {} - - public function name(): string { return $this->name; } - public function version(): string { return $this->version; } - - /** @return array */ - public function payload(): array { return $this->payload; } -} diff --git a/plugins/User/API/IntegrationEvents/UserDeletedIntegrationEvent.php b/plugins/User/API/IntegrationEvents/UserDeletedIntegrationEvent.php deleted file mode 100644 index f9be88a..0000000 --- a/plugins/User/API/IntegrationEvents/UserDeletedIntegrationEvent.php +++ /dev/null @@ -1,42 +0,0 @@ -version = '1.0'; - } - - public function name(): string - { - return 'user.deleted'; - } - - public function version(): string - { - return $this->version; - } - - /** @return array */ - public function payload(): array - { - return [ - 'userId' => $this->userId, - 'occurredAt' => $this->occurredAt, - 'version' => $this->version, - ]; - } -} diff --git a/plugins/User/API/IntegrationEvents/UserRegisteredIntegrationEvent.php b/plugins/User/API/IntegrationEvents/UserRegisteredIntegrationEvent.php deleted file mode 100644 index 46d9a52..0000000 --- a/plugins/User/API/IntegrationEvents/UserRegisteredIntegrationEvent.php +++ /dev/null @@ -1,60 +0,0 @@ - - */ - public array $profile = [], - ) { - $this->version = '1.1'; - } - - public function name(): string - { - return 'user.registered'; - } - - public function version(): string - { - return $this->version; - } - - /** @return array */ - public function payload(): array - { - return [ - 'userId' => $this->userId, - 'username' => $this->username, - 'email' => $this->email, - 'occurredAt' => $this->occurredAt, - 'tenantId' => $this->tenantId, - 'profile' => $this->profile, - 'version' => $this->version, - ]; - } -} diff --git a/plugins/User/API/IntegrationEvents/UserUpdatedIntegrationEvent.php b/plugins/User/API/IntegrationEvents/UserUpdatedIntegrationEvent.php deleted file mode 100644 index ffac200..0000000 --- a/plugins/User/API/IntegrationEvents/UserUpdatedIntegrationEvent.php +++ /dev/null @@ -1,46 +0,0 @@ - $changed */ - public function __construct( - public string $userId, - public array $changed, - public string $occurredAt, - ) { - $this->version = '1.0'; - } - - public function name(): string - { - return 'user.updated'; - } - - public function version(): string - { - return $this->version; - } - - /** @return array */ - public function payload(): array - { - return [ - 'userId' => $this->userId, - 'changed' => $this->changed, - 'occurredAt' => $this->occurredAt, - 'version' => $this->version, - ]; - } -} diff --git a/plugins/User/Application/Ports/BreachChecker.php b/plugins/User/Application/Ports/BreachChecker.php deleted file mode 100644 index 09ae206..0000000 --- a/plugins/User/Application/Ports/BreachChecker.php +++ /dev/null @@ -1,19 +0,0 @@ -, 1: bool} [entries, hasMore] - */ - public function paginate(ListFeedbackQuery $query): array; - - /** Persist a status transition. Returns false if the row no longer exists. */ - public function updateStatus(string $feedbackId, string $status): bool; -} diff --git a/plugins/User/Application/Ports/OutboxPort.php b/plugins/User/Application/Ports/OutboxPort.php deleted file mode 100644 index 5db80ac..0000000 --- a/plugins/User/Application/Ports/OutboxPort.php +++ /dev/null @@ -1,25 +0,0 @@ -, 1: bool} [users, hasMore] - */ - public function paginate(ListUsersQuery $query): array; - - public function find(string $userId): ?User; - - public function findByIdentifier(string $identifier): ?User; - - /** Look up an active user by the SHA-256 hash of a "remember me" token. */ - public function findByRememberToken(string $tokenHash): ?User; - - /** Look up an active user by the SHA-256 hash of a pending verification token. */ - public function findByVerificationTokenHash(string $tokenHash): ?User; - - /** Persist (or clear, with null) the remember-token hash for a user. */ - public function updateRememberToken(string $userId, ?string $tokenHash): void; - - public function existsByUsernameOrEmail(string $username, string $email, ?string $exceptUserId = null): bool; - - /** - * Persist a new user. Passed by reference so the store can reflect the - * persisted timestamps (created_at / updated_at) back onto the entity, - * leaving the caller with a fully-synced aggregate. - */ - public function insert(User &$user): void; - - public function update(User $user): void; - - public function persistRehash(string $userId, string $passwordHash): void; - - public function delete(string $userId): bool; -} diff --git a/plugins/User/Application/Services/.gitkeep b/plugins/User/Application/Services/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/User/Application/Services/FeedbackService.php b/plugins/User/Application/Services/FeedbackService.php deleted file mode 100644 index 373679a..0000000 --- a/plugins/User/Application/Services/FeedbackService.php +++ /dev/null @@ -1,181 +0,0 @@ -identity->isGuest()) { - throw new SecurityException( - 'feedback.submit.unauthenticated', - layer: 'service.feedback', - ); - } - - $entry = FeedbackEntry::submit( - userId: $this->identity->userId, - category: $dto->category, - rating: $dto->rating, - message: $dto->message, - ); - - // A single tenant-scoped INSERT is atomic on its own. We deliberately do - // NOT use the kernel TransactionManager here: it is constructed against - // the CENTRAL DatabasePort, whereas this repository writes to the - // request's TENANT connection — wrapping it would open an idle central - // transaction that never covers the tenant write. - try { - $this->repository->insert($entry); - } catch (\Throwable $e) { - throw $this->wrap($e, 'feedback.submit.failed'); - } - - // Integration event AFTER the write succeeds. - $this->eventBus->dispatch(new FeedbackSubmittedIntegrationEvent( - feedbackId: $entry->id()->value(), - userId: $entry->userId(), - category: $entry->category()?->value, - rating: $entry->rating()?->value(), - occurredAt: $entry->createdAt()->format(\DateTimeInterface::RFC3339), - )); - - $this->audit->record('feedback.submitted', ['feedbackId' => $entry->id()->value()]); - - return $entry; - } - - public function find(string $feedbackId): ?FeedbackEntry - { - $entry = $this->repository->find($feedbackId); - if ($entry === null) { - return null; - } - - // Self-or-admin: a user may read only their own feedback. - if (!$entry->isOwnedBy($this->identity->userId) - && !$this->identity->hasPermission(self::PERMISSION_MANAGE)) { - throw new SecurityException( - 'feedback.read.forbidden', - layer: 'service.feedback', - context: ['feedbackId' => $feedbackId], - ); - } - - return $entry; - } - - public function list(ListFeedbackQuery $query): FeedbackPage - { - $this->requireManage(); - - [$entries, $hasMore] = $this->repository->paginate($query); - - return new FeedbackPage( - items: $entries, - hasMore: $hasMore, - limit: $query->limit, - ); - } - - public function updateStatus(string $feedbackId, string $status): ?FeedbackEntry - { - $this->requireManage(); - - $entry = $this->repository->find($feedbackId); - if ($entry === null) { - return null; - } - - // Validate + apply the transition on the entity (forward-only) before - // touching the database — an illegal jump throws a 422. - try { - $entry->transitionTo(FeedbackStatus::fromString($status)); - } catch (\DomainException $e) { - throw new ValidationException(['status' => $e->getMessage()]); - } - - try { - $updated = $this->repository->updateStatus($feedbackId, $entry->status()->value); - } catch (\Throwable $e) { - throw $this->wrap($e, 'feedback.update_status.failed', ['feedbackId' => $feedbackId]); - } - - // The row vanished between read and write (concurrent delete). - if (!$updated) { - return null; - } - - $this->audit->record('feedback.status_changed', [ - 'feedbackId' => $feedbackId, - 'status' => $entry->status()->value, - ]); - - return $entry; - } - - private function requireManage(): void - { - if (!$this->identity->hasPermission(self::PERMISSION_MANAGE)) { - throw new SecurityException( - 'feedback.manage.forbidden', - layer: 'service.feedback', - ); - } - } - - private function wrap(\Throwable $e, string $code, array $context = []): \Throwable - { - // Preserve typed faults so the kernel maps them to the right HTTP status. - if ($e instanceof ServiceException - || $e instanceof ValidationException - || $e instanceof SecurityException - || $e instanceof \AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\DomainException - ) { - return $e; - } - - return new ServiceException($code, layer: 'service.feedback', context: $context, previous: $e); - } -} diff --git a/plugins/User/Application/Services/OutboxRelayService.php b/plugins/User/Application/Services/OutboxRelayService.php deleted file mode 100644 index 98fca8a..0000000 --- a/plugins/User/Application/Services/OutboxRelayService.php +++ /dev/null @@ -1,55 +0,0 @@ -outbox->pending($limit) as $row) { - try { - $payload = json_decode((string) $row['payload'], true, 512, JSON_THROW_ON_ERROR); - - $this->eventBus->dispatch(new GenericIntegrationEvent( - name: (string) $row['event_name'], - version: (string) $row['event_version'], - payload: is_array($payload) ? $payload : [], - )); - - $this->outbox->markDispatched((int) $row['id']); - $dispatched++; - } catch (\Throwable $e) { - $this->outbox->markFailed((int) $row['id'], (int) $row['attempts'] + 1, $e->getMessage()); - } - } - - return $dispatched; - } -} diff --git a/plugins/User/Application/Services/TenantProfileProvisioner.php b/plugins/User/Application/Services/TenantProfileProvisioner.php deleted file mode 100644 index 47095a8..0000000 --- a/plugins/User/Application/Services/TenantProfileProvisioner.php +++ /dev/null @@ -1,130 +0,0 @@ - $profile whitelisted primitive fields - * (first_name, last_name, phone, timezone, locale) from the event. - * @param string $tenantId tenant to write to — required in resolver mode, - * ignored when a pinned repository was injected. - */ - public function provision(string $userId, array $profile, string $tenantId = ''): void - { - if ($userId === '' || $profile === []) { - return; - } - - $repository = $this->repositoryFor($tenantId); - if ($repository === null) { - return; - } - - // Named constructor validates lengths/locale/timezone; omitted fields - // fall back to the table's defaults inside the entity. - $entity = UserProfile::fromInput( - userId: $userId, - firstName: $profile['first_name'] ?? null, - lastName: $profile['last_name'] ?? null, - avatarUrl: null, - timezone: $profile['timezone'] ?? null, - locale: $profile['locale'] ?? null, - phone: $profile['phone'] ?? null, - ); - - $repository->saveProfile($entity); - } - - /** - * "First Last" from the tenant's user_profiles row. Best-effort: a missing - * profile, unreachable tenant DB, or unresolvable connection yields '' — - * display data never fails the calling flow (contract guarantee). - */ - public function fullName(string $userId, string $tenantId = ''): string - { - if ($userId === '') { - return ''; - } - - try { - $profile = $this->repositoryFor($tenantId)?->findProfile($userId); - } catch (\Throwable) { - return ''; - } - - if ($profile === null) { - return ''; - } - - return trim(trim((string) $profile->firstName()) . ' ' . trim((string) $profile->lastName())); - } - - public function getProfile(string $userId, string $tenantId = ''): ?UserProfile - { - if ($userId === '') { - return null; - } - try { - $profile = $this->repositoryFor($tenantId)?->findProfile($userId); - return $profile; - } catch (\Throwable) { - return null; - } - } - - /** - * The repository for this call: the pinned one when injected, else one - * composed against the tenant connection resolved from $tenantId. - */ - private function repositoryFor(string $tenantId): ?UserSettingsRepository - { - if ($this->profiles !== null) { - return $this->profiles; - } - - if ($this->connections === null || $tenantId === '') { - return null; - } - - return new UserSettingsRepository($this->connections->for($tenantId)); - } -} diff --git a/plugins/User/Application/Services/UserService.php b/plugins/User/Application/Services/UserService.php deleted file mode 100644 index b55aaef..0000000 --- a/plugins/User/Application/Services/UserService.php +++ /dev/null @@ -1,769 +0,0 @@ -requirePermission('user:list'); - - [$users, $hasMore] = $this->repository->paginate($query); - - return new UserPage( - items: array_map(static fn(User $u): UserDTO => UserDTO::fromEntity($u), $users), - hasMore: $hasMore, - limit: $query->limit, - ); - } - - /** Verification token lifetime (seconds) — 24h. */ - private const VERIFICATION_TTL = 86400; - - /** - * ADMIN / back-office registration. Returns the FULL user record so it can - * be shown in an admin table. Still arms an email-verification token; when - * you need the plaintext token to email, use registerPublic() instead. - */ - public function register(RegisterUserDTO $dto): UserDTO - { - [$user] = $this->provision($dto); - return $user; - } - - /** - * PUBLIC self-signup. Returns ONLY the plaintext verification token for the - * caller to email — never the identity record. A public registrant must not - * receive their id/email/verification state back, so the controller responds - * with a fixed "pending" status and this token stays server-side. - */ - public function registerPublic(RegisterUserDTO $dto): string - { - [$_, $token] = $this->provision($dto); - return $token; - } - - /** - * Confirm an email from the PUBLIC (unauthenticated) verification link. The - * emailed token is matched by its stored SHA-256 hash and must not be - * expired. One-time: verifyEmail() clears the token on success. Returns - * false on any miss (unknown/expired/consumed) so a forged token reveals - * nothing. - */ - public function verifyEmailByToken(string $token): VerifyEmailResult - { - if ($token === '') { - return VerifyEmailResult::invalid(); - } - - $user = $this->repository->findByVerificationTokenHash(hash('sha256', $token)); - if ($user === null) { - $this->audit->record('user.email_verify.token_miss'); - return VerifyEmailResult::invalid(); - } - - // The token HASH matched a real pending user — so possession is already - // proven. If it is merely expired we can safely say so (and steer the - // holder to resend) without aiding enumeration: a forged/unknown token - // never reaches this branch (it fails the hash lookup above → INVALID). - $expiresAt = $user->emailVerificationExpiresAt(); - if ($expiresAt === null || $expiresAt < new \DateTimeImmutable()) { - $this->audit->record('user.email_verify.token_expired', userId: $user->id()); - return VerifyEmailResult::expired($user->email()); - } - - // Proof of control established: a valid, unexpired token. Only now — when - // the caller demonstrably holds the emailed secret — is it safe to - // disclose that the account is already verified (the open resend form - // never reveals this). The token hash survives verification precisely so - // a second click resolves here instead of a confusing "invalid link". - if ($user->isEmailVerified()) { - $this->audit->record('user.email_verify.already', userId: $user->id()); - return VerifyEmailResult::already($user->email()); - } - - $this->collector->beginCollection(); - $this->transaction->begin(); - try { - $user->verifyEmail(); - $user->commitChanges(); - $pending = $this->flushEvents($user); - $this->repository->update($user); - $this->transaction->commit(); - } catch (\Throwable $e) { - $this->transaction->rollback(); - $this->collector->discard(); - throw $this->wrap($e, 'user.verify_email.failed', ['id' => $user->id()]); - } - - $this->collector->release(); - $this->deliver($pending); - $this->audit->record('user.email_verified', userId: $user->id()); - - return VerifyEmailResult::ok(); - } - - /** - * PUBLIC re-issue of a verification token. Enumeration-safe: returns null — - * with no observable difference — when the email is unknown OR already - * verified, so callers respond generically. When the account exists and is - * unverified, a FRESH token is armed (replacing any pending one, so an old - * link stops working) and its plaintext returned for the caller to email. - */ - public function resendVerification(string $email): ?string - { - if ($email === '') { - return null; - } - - $user = $this->repository->findByIdentifier($email); - - if ($user === null) { - $this->audit->record('user.email_verify.resend_miss'); - return null; - } - if ($user->isEmailVerified()) { - // Nothing to send — an already-active account must not be re-armed. - $this->audit->record('user.email_verify.resend_noop', userId: $user->id()); - return null; - } - - // Same mechanism as signup: emailed once, only the SHA-256 stored, - // time-boxed + one-time. Re-arming invalidates the previous token. - $plainToken = bin2hex(random_bytes(32)); - $expiresAt = (new \DateTimeImmutable())->modify('+' . self::VERIFICATION_TTL . ' seconds'); - - $this->transaction->begin(); - try { - $user->startEmailVerification(hash('sha256', $plainToken), $expiresAt); - $user->commitChanges(); - $this->repository->update($user); - $this->transaction->commit(); - } catch (\Throwable $e) { - $this->transaction->rollback(); - throw $this->wrap($e, 'user.verify_email.resend_failed', ['id' => $user->id()]); - } - - $this->audit->record('user.email_verify.resent', userId: $user->id()); - - return $plainToken; - } - - /** - * Shared registration core — arms a verification token and persists identity. - * - * @return array{0: UserDTO, 1: string} [record, plaintext verification token] - */ - private function provision(RegisterUserDTO $dto): array - { - // Cheap pre-check for a friendly 422 before we hit the unique index; - // the index + DuplicateUserException is the authoritative guard. - if ($this->repository->existsByUsernameOrEmail($dto->username->value(), $dto->email->value())) { - throw new ValidationException(['username' => 'Username or email is already taken.']); - } - - $this->assertNotBreached($dto->password); - - // Emailed once; only its hash is stored. Time-boxed + one-time. - $plainToken = bin2hex(random_bytes(32)); - $expiresAt = (new \DateTimeImmutable())->modify('+' . self::VERIFICATION_TTL . ' seconds'); - - $this->collector->beginCollection(); - $this->transaction->begin(); - try { - $user = User::register( - username: $dto->username, - email: $dto->email, - passwordHash: $this->hasher->make($dto->password), - ); - $user->startEmailVerification(hash('sha256', $plainToken), $expiresAt); - - // Persist the identity row FIRST, so the outbox event (and its - // userId) is only written once the user actually exists — both land - // in the same central transaction and commit atomically. - $this->repository->insert($user); - - // Profile (if submitted) rides on the event for a tenant-side write; - // it CANNOT join this central identity transaction (different DB). - $pending = $this->flushEvents($user, $dto->tenantId, $dto->profile); - $this->transaction->commit(); - } catch (\Throwable $e) { - $this->transaction->rollback(); - $this->collector->discard(); - throw $this->wrap($e, 'user.register.failed'); - } - - $this->collector->release(); - $this->deliver($pending); - $this->audit->record('user.registered', userId: $user->id()); - - return [UserDTO::fromEntity($user), $plainToken]; - } - - public function find(string $id, bool $checkMembership = false, bool $isAuth = false): ?UserDTO - { - if (!$isAuth) - $this->requireSelfOrPermission($id, 'user:read-any'); - - $user = $this->repository->find($id); - if ($checkMembership) { - $membership = $this->membership !== null && $this->tenantId !== null && $user !== null - ? $this->membership->activeMember($user->id(), $this->tenantId) - : null; - - if (is_null($membership) && $this->tenantId !== null) { - $this->audit->record('user.login.no_membership', meta: ['id' => self::pseudonymise($id), 'tenantId' => $this->tenantId]); - return null; - } - - $user?->setMembership($membership); - } - if ($user === null) { - return null; - } - - if ($this->profiles !== null) { - $user->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); - } - - $dto = UserDTO::fromEntity($user); - - return $dto; - } - - public function update(string $id, UpdateUserDTO $dto): ?UserDTO - { - $this->requireSelfOrPermission($id, 'user:update-any'); - - $user = $this->repository->find($id); - if ($user === null) { - return null; - } - if (!$dto->hasChanges()) { - return UserDTO::fromEntity($user); - } - - $newUsername = $dto->username?->value() ?? $user->username(); - $newEmail = $dto->email?->value() ?? $user->email(); - if ($this->repository->existsByUsernameOrEmail($newUsername, $newEmail, exceptUserId: $id)) { - throw new ValidationException(['username' => 'Username or email is already taken.']); - } - - if ($dto->password !== null) { - $this->assertNotBreached($dto->password); - } - - $this->collector->beginCollection(); - $this->transaction->begin(); - try { - if ($dto->username !== null) { - $user->rename($dto->username); - } - if ($dto->email !== null) { - $user->changeEmail($dto->email); - } - if ($dto->password !== null) { - $user->changePassword($this->hasher->make($dto->password)); - } - - if (!$user->commitChanges()) { - $this->transaction->rollback(); - $this->collector->discard(); - return UserDTO::fromEntity($user); - } - - $pending = $this->flushEvents($user); - $this->repository->update($user); - $this->transaction->commit(); - } catch (\Throwable $e) { - $this->transaction->rollback(); - $this->collector->discard(); - throw $this->wrap($e, 'user.update.failed', ['id' => $id]); - } - - $this->collector->release(); - $this->deliver($pending); - $this->audit->record('user.updated', userId: $id); - - return UserDTO::fromEntity($user); - } - - public function verifyEmail(string $id, VerifyEmailDTO $dto): ?UserDTO - { - // The caller (or an Auth module) issues the token; here we accept it for - // the user being acted on. Self-or-admin still applies. - $this->requireSelfOrPermission($id, 'user:update-any'); - - $user = $this->repository->find($id); - if ($user === null) { - return null; - } - - $this->collector->beginCollection(); - $this->transaction->begin(); - try { - $user->verifyEmail(); - if (!$user->commitChanges()) { - $this->transaction->rollback(); - $this->collector->discard(); - return UserDTO::fromEntity($user); // already verified — idempotent - } - - $pending = $this->flushEvents($user); - $this->repository->update($user); - $this->transaction->commit(); - } catch (\Throwable $e) { - $this->transaction->rollback(); - $this->collector->discard(); - throw $this->wrap($e, 'user.verify_email.failed', ['id' => $id]); - } - - $this->collector->release(); - $this->deliver($pending); - $this->audit->record('user.email_verified', userId: $id); - - return UserDTO::fromEntity($user); - } - - public function verifyCredentials(string $identifier, string $password): ?UserDTO - { - - try { - // 1. Lockout gate — refuse before any DB/hash work. - if ($this->isLockedOut($identifier)) { - $this->audit->record('user.login.locked_out', meta: ['id' => self::pseudonymise($identifier)]); - return null; - } - $user = $this->repository->findByIdentifier($identifier); - $membership = $this->membership !== null && $this->tenantId !== null && $user !== null - ? $this->membership->activeMember($user->id(), $this->tenantId) - : null; - - if (is_null($membership) && $this->tenantId !== null) { - $this->audit->record('user.login.no_membership', meta: ['id' => self::pseudonymise($identifier), 'tenantId' => $this->tenantId]); - return null; - } - - $user?->setMembership($membership); - - if ($this->profiles !== null) { - $user?->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); - } - - // 2. Timing-safe: run a hash comparison even when the user is unknown. - $hash = $user?->passwordHash() ?? self::DECOY_HASH; - $ok = $this->hasher->check($password, $hash); - - - if (!$ok || $user === null || !$user->canLogin()) { - $this->recordLoginFailure($identifier); - $this->audit->record('user.login.failed', meta: ['id' => self::pseudonymise($identifier)]); - - - return null; - } - // 3. Success — clear failures and transparently upgrade the hash if the - // cost factor changed since it was created. - $this->clearLoginFailures($identifier); - if ($this->hasher->needsRehash($hash)) { - try { - $this->repository->persistRehash($user->id(), $this->hasher->make($password)); - $this->audit->record('user.password.rehashed', userId: $user->id()); - } catch (\Throwable) { - // A rehash failure must never block a valid login. - } - } - - - return UserDTO::fromEntity($user); - - } catch (\Throwable $e) { - throw $this->wrap($e, 'user.verify_credentials.failed'); - } - - } - - public function findByIdentifier(string $identifier, bool $checkMembership = false): ?UserDTO - { - if ($identifier === '') { - return null; - } - - $user = $this->repository->findByIdentifier($identifier); - - if ($checkMembership) { - $membership = $this->membership !== null && $this->tenantId !== null && $user !== null - ? $this->membership->activeMember($user->id(), $this->tenantId) - : null; - - if (is_null($membership) && $this->tenantId !== null) { - $this->audit->record('user.login.no_membership', meta: ['id' => self::pseudonymise($identifier), 'tenantId' => $this->tenantId]); - return null; - } - - $user?->setMembership($membership); - } - if ($this->profiles !== null) { - $user?->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); - } - - return $user === null ? null : UserDTO::fromEntity($user); - } - - public function resetPassword(string $userId, string $newPassword): bool - { - $user = $this->repository->find($userId); - if ($user === null) { - return false; - } - - $this->assertNotBreached($newPassword); - - $this->transaction->begin(); - try { - $user->changePassword($this->hasher->make($newPassword)); - $user->commitChanges(); - $this->repository->update($user); - // Invalidate outstanding "remember me" cookies after a reset. - $this->repository->updateRememberToken($userId, null); - $this->transaction->commit(); - } catch (\Throwable $e) { - $this->transaction->rollback(); - throw $this->wrap($e, 'user.password.reset_failed', ['id' => $userId]); - } - - $this->audit->record('user.password.reset', userId: $userId); - - return true; - } - - public function findByRememberToken(string $token): ?UserDTO - { - if ($token === '') { - return null; - } - - $user = $this->repository->findByRememberToken(hash('sha256', $token)); - $membership = $this->membership !== null && $this->tenantId !== null && $user !== null - ? $this->membership->activeMember($user->id(), $this->tenantId) - : null; - - if (is_null($membership) && $this->tenantId !== null) { - $this->audit->record('user.find_by_token.no_membership', meta: ['id' => self::pseudonymise($token), 'tenantId' => $this->tenantId]); - return null; - } - - $user?->setMembership($membership); - if ($user === null || !$user->canLogin()) { - return null; - } - - if ($this->profiles !== null) { - $user->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); - } - - return UserDTO::fromEntity($user); - } - - public function cycleRememberToken(string $userId, bool $checkMembership = false): string - { - $plaintext = bin2hex(random_bytes(32)); - $this->repository->updateRememberToken($userId, hash('sha256', $plaintext)); - - return $plaintext; - } - - public function clearRememberToken(string $userId, bool $checkMembership = false): void - { - $this->repository->updateRememberToken($userId, null); - } - - public function delete(string $id, bool $checkMembership = false): bool - { - $this->requireSelfOrPermission($id, 'user:delete-any'); - - $user = $this->repository->find($id); - - if ($user === null) { - return false; - } - if ($checkMembership) { - - $membership = $this->membership !== null && $this->tenantId !== null && $user !== null - ? $this->membership->activeMember($user->id(), $this->tenantId) - : null; - - if (is_null($membership) && $this->tenantId !== null) { - $this->audit->record('user.delete.no_membership', meta: ['id' => self::pseudonymise($id), 'tenantId' => $this->tenantId]); - return false; - } - - $user?->setMembership($membership); - } - if ($this->profiles !== null) { - $user?->setProfile($this->profiles->getProfile($user->id(), $this->tenantId ?? '')); - } - - $this->collector->beginCollection(); - $this->transaction->begin(); - try { - $deleted = $this->repository->delete($id); - if (!$deleted) { - $this->transaction->rollback(); - $this->collector->discard(); - return false; - } - - $user->markDeleted(); - $pending = $this->flushEvents($user); - $this->transaction->commit(); - } catch (\Throwable $e) { - $this->transaction->rollback(); - $this->collector->discard(); - throw $this->wrap($e, 'user.delete.failed', ['id' => $id]); - } - - $this->collector->release(); - if (count($pending) > 0) { - $this->deliver($pending); - } - $this->audit->record('user.deleted', userId: $id); - - return true; - } - - // ─── internals ────────────────────────────────────────────────────────── - - /** - * Collect the entity's domain events and write their integration - * counterparts to the outbox — all inside the active transaction. - */ - /** - * Collect the entity's domain events and write their integration - * counterparts to the outbox — all inside the active transaction. Returns - * the written rows keyed by outbox id so the caller can dispatch them - * in-process after commit (see deliver()). - * - * @return array - */ - private function flushEvents(User $user, string $originTenant = '', array $profile = []): array - { - $pending = []; - - foreach ($user->releaseEvents() as $event) { - $this->collector->collect($event); - - $integration = $this->toIntegration($event, $originTenant, $profile); - if ($integration !== null) { - /** incase you want all event to fire there and then */ - // $pending[$this->outbox->write($integration)] = $integration; - $this->outbox->write($integration); - } - } - - return $pending; - } - - /** - * Dispatch the just-committed integration events in-process and mark their - * outbox rows dispatched. The EventBus isolates listener failures, so a bad - * subscriber never blocks the mark. The relay therefore only re-delivers - * rows a crash between commit and dispatch left pending — at-least-once with - * no double-fire on the happy path. - * - * @param array $pending - */ - private function deliver(array $pending): void - { - foreach ($pending as $id => $event) { - $this->eventBus->dispatch($event); - $this->outbox->markDispatched($id); - } - } - - private function toIntegration(DomainEventContract $event, string $originTenant = '', array $profile = []): ?IntegrationEventContract - { - return match (true) { - $event instanceof UserRegisteredDomainEvent => new UserRegisteredIntegrationEvent( - userId: $event->userId->value(), - username: $event->username->value(), - email: $event->email->value(), - occurredAt: $event->occurredAt->format(\DateTimeInterface::RFC3339), - tenantId: $originTenant, - profile: $profile, - ), - $event instanceof UserUpdatedDomainEvent => new UserUpdatedIntegrationEvent( - userId: $event->userId->value(), - changed: $event->changed, - occurredAt: $event->occurredAt->format(\DateTimeInterface::RFC3339), - ), - $event instanceof UserDeletedDomainEvent => new UserDeletedIntegrationEvent( - userId: $event->userId->value(), - occurredAt: $event->occurredAt->format(\DateTimeInterface::RFC3339), - ), - default => null, - }; - } - - private function wrap(\Throwable $e, string $code, array $context = []): \Throwable - { - // Preserve typed domain/security/validation faults so the kernel maps - // them to the right HTTP status (409/422/403) instead of a blanket 500. - if ( - $e instanceof ServiceException - || $e instanceof ValidationException - || $e instanceof SecurityException - || $e instanceof \AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\DomainException - || $e instanceof \AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\OptimisticLockException - ) { - return $e; - } - - return new ServiceException($code, layer: 'service.user', context: $context, previous: $e); - } - - // ─── credential screening ──────────────────────────────────────────────── - - /** - * Reject a password known to appear in a breach corpus (NIST 800-63B). - * No-op when screening is disabled. The checker itself fails open on a - * provider outage, so this only ever throws on a confirmed breach hit. - */ - private function assertNotBreached(string $plain): void - { - if ($this->breachChecker !== null && $this->breachChecker->isBreached($plain)) { - throw new ValidationException([ - 'password' => 'This password has appeared in a known data breach. Please choose a different one.', - ]); - } - } - - // ─── authorization ────────────────────────────────────────────────────── - - private function requirePermission(string $permission): void - { - if (!$this->identity->hasPermission($permission)) { - throw new SecurityException('user.forbidden', layer: 'service.user', context: ['permission' => $permission]); - } - } - - private function requireSelfOrPermission(string $targetUserId, string $permission): void - { - if ($this->identity->isGuest()) { - throw new SecurityException('user.unauthenticated', layer: 'service.user'); - } - if (hash_equals($this->identity->userId, $targetUserId)) { - return; - } - $this->requirePermission($permission); - } - - // ─── lockout (CachePort) ───────────────────────────────────────────────── - - private function isLockedOut(string $identifier): bool - { - return (int) ($this->cache->get($this->lockKey($identifier)) ?? 0) >= self::MAX_LOGIN_FAILURES; - } - - private function recordLoginFailure(string $identifier): void - { - $key = $this->lockKey($identifier); - if (!$this->cache->has($key)) { - $this->cache->set($key, 0, self::LOCKOUT_WINDOW); - } - $this->cache->increment($key); - } - - private function clearLoginFailures(string $identifier): void - { - $this->cache->delete($this->lockKey($identifier)); - } - - private function lockKey(string $identifier): string - { - // Hash the identifier (PII). Identity is global (central users table). - return 'user:login:fail:' . hash('sha256', mb_strtolower($identifier)); - } - - /** Short, non-reversible tag for audit logs (no raw email/username). */ - private static function pseudonymise(string $identifier): string - { - return substr(hash('sha256', mb_strtolower($identifier)), 0, 12); - } -} diff --git a/plugins/User/Application/Services/UserSettingsService.php b/plugins/User/Application/Services/UserSettingsService.php deleted file mode 100644 index b62bceb..0000000 --- a/plugins/User/Application/Services/UserSettingsService.php +++ /dev/null @@ -1,176 +0,0 @@ -requireUser(); - - return $this->repository->findProfile($userId) ?? UserProfile::defaults($userId); - } - - public function updateProfile(UpdateProfileDTO $dto): UserProfile - { - $userId = $this->requireUser(); - - try { - $profile = UserProfile::fromInput( - userId: $userId, - firstName: $dto->firstName, - lastName: $dto->lastName, - avatarUrl: $dto->avatarUrl, - timezone: $dto->timezone, - locale: $dto->locale, - phone: $dto->phone, - ); - } catch (\DomainException $e) { - throw new ValidationException(['profile' => $e->getMessage()]); - } - - $this->repository->saveProfile($profile); - $this->audit->record('user.profile.updated', userId: $userId); - - return $profile; - } - - // ── preferences ───────────────────────────────────────────────────────────── - - public function getPreferences(): UserPreferences - { - $userId = $this->requireUser(); - - return $this->repository->findPreferences($userId) ?? UserPreferences::defaults($userId); - } - - public function updatePreferences(UpdatePreferencesDTO $dto): UserPreferences - { - $userId = $this->requireUser(); - - try { - $prefs = UserPreferences::fromInput( - userId: $userId, - language: $dto->language, - currency: $dto->currency, - theme: $dto->theme, - reduceMotion: $dto->reduceMotion, - largerText: $dto->largerText, - highContrast: $dto->highContrast, - screenReaderHints: $dto->screenReaderHints, - ); - } catch (\DomainException $e) { - throw new ValidationException(['preferences' => $e->getMessage()]); - } - - $this->repository->savePreferences($prefs); - $this->audit->record('user.preferences.updated', userId: $userId); - - return $prefs; - } - - // ── privacy ───────────────────────────────────────────────────────────────── - - public function getPrivacy(): UserPrivacySettings - { - $userId = $this->requireUser(); - - return $this->repository->findPrivacy($userId) ?? UserPrivacySettings::defaults($userId); - } - - public function updatePrivacy(UpdatePrivacyDTO $dto): UserPrivacySettings - { - $userId = $this->requireUser(); - - $settings = UserPrivacySettings::fromInput( - userId: $userId, - profileVisibility: $dto->profileVisibility, - showPhone: $dto->showPhone, - showEmail: $dto->showEmail, - marketingOptIn: $dto->marketingOptIn, - analyticsOptIn: $dto->analyticsOptIn, - ); - - $this->repository->savePrivacy($settings); - // Privacy/marketing toggles are compliance-relevant — record the change. - $this->audit->record('user.privacy.updated', userId: $userId, meta: [ - 'marketingOptIn' => $dto->marketingOptIn, - 'analyticsOptIn' => $dto->analyticsOptIn, - ]); - - return $settings; - } - - // ── notification preferences ────────────────────────────────────────────────── - - public function getNotifications(): UserNotificationPreferences - { - $userId = $this->requireUser(); - - return $this->repository->findNotifications($userId) ?? UserNotificationPreferences::defaults($userId); - } - - public function updateNotifications(UpdateNotificationPreferencesDTO $dto): UserNotificationPreferences - { - $userId = $this->requireUser(); - - // Merge the provided flags over the user's current set (or defaults), so - // a partial payload only changes what it names (security topics stay on). - $current = $this->repository->findNotifications($userId) ?? UserNotificationPreferences::defaults($userId); - - try { - $prefs = UserNotificationPreferences::fromInput($userId, [...$current->flags(), ...$dto->flags]); - } catch (\DomainException $e) { - throw new ValidationException(['flags' => $e->getMessage()]); - } - - $this->repository->saveNotifications($prefs); - $this->audit->record('user.notification_preferences.updated', userId: $userId); - - return $prefs; - } - - // ── internals ──────────────────────────────────────────────────────────────── - - private function requireUser(): string - { - if ($this->identity->isGuest()) { - throw new SecurityException('user_settings.unauthenticated', layer: 'service.user_settings'); - } - return $this->identity->userId; - } -} diff --git a/plugins/User/Domain/.gitkeep b/plugins/User/Domain/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/User/Domain/Entities/FeedbackEntry.php b/plugins/User/Domain/Entities/FeedbackEntry.php deleted file mode 100644 index fd84b21..0000000 --- a/plugins/User/Domain/Entities/FeedbackEntry.php +++ /dev/null @@ -1,102 +0,0 @@ - */ - protected array $casts = [ - 'rating' => '?int', - 'created_at' => 'datetime', - ]; - - /** - * Submit brand-new feedback. The cross-module announcement is the - * FeedbackSubmittedIntegrationEvent dispatched by the service after the - * write; this aggregate records no in-process domain events. - */ - public static function submit( - string $userId, - ?FeedbackCategory $category, - ?FeedbackRating $rating, - FeedbackMessage $message, - ): self { - if ($userId === '' || mb_strlen($userId) > 31) { - throw new \DomainException('FeedbackEntry requires a valid user id.'); - } - - $e = (new self())->forceFill([ - 'feedback_id' => FeedbackId::generate()->value(), - 'user_id' => $userId, - 'category' => $category?->value, - 'rating' => $rating?->value(), - 'message' => $message->value(), - 'status' => FeedbackStatus::Received->value, - 'created_at' => new \DateTimeImmutable(), - ]); - $e->syncOriginal(); - - return $e; - } - - /** Advance triage state (forward-only). */ - public function transitionTo(FeedbackStatus $next): void - { - $current = $this->status(); - if ($next === $current) { - return; - } - if (!$current->canTransitionTo($next)) { - throw new \DomainException( - "Cannot move feedback from {$current->value} to {$next->value}." - ); - } - $this->setAttribute('status', $next->value); - } - - public function isOwnedBy(string $userId): bool - { - return hash_equals($this->userId(), $userId); - } - - public function id(): FeedbackId { return FeedbackId::fromString($this->getString('feedback_id')); } - public function userId(): string { return $this->getString('user_id'); } - public function category(): ?FeedbackCategory { $v = $this->getRawAttribute('category'); return $v === null ? null : FeedbackCategory::from((string) $v); } - public function rating(): ?FeedbackRating { $v = $this->getRawAttribute('rating'); return $v === null ? null : FeedbackRating::of((int) $v); } - public function message(): FeedbackMessage { return FeedbackMessage::fromString($this->getString('message')); } - public function status(): FeedbackStatus { return FeedbackStatus::from($this->getString('status')); } - public function createdAt(): \DateTimeImmutable { return $this->getDate('created_at') ?? new \DateTimeImmutable(); } - - /** @return array Camel-cased API shape (not the DB shape). */ - public function toArray(bool $onlyChanged = false): array - { - return [ - 'feedbackId' => $this->id()->value(), - 'userId' => $this->userId(), - 'category' => $this->category()?->value, - 'rating' => $this->rating()?->value(), - 'message' => $this->message()->value(), - 'status' => $this->status()->value, - 'createdAt' => $this->createdAt()->format(\DateTimeInterface::RFC3339), - ]; - } -} diff --git a/plugins/User/Domain/Entities/User.php b/plugins/User/Domain/Entities/User.php deleted file mode 100644 index 885c05a..0000000 --- a/plugins/User/Domain/Entities/User.php +++ /dev/null @@ -1,294 +0,0 @@ - */ - protected array $casts = [ - 'version' => 'int', - // Entity short-circuits casts on null, so a plain (non-nullable) datetime - // cast is correct here — a '?datetime' would leak a 'nullable' param into - // DatetimeCast and be misread as a literal date format. - 'email_verified_at' => 'datetime', - 'email_verification_expires_at' => 'datetime', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - ]; - - /** Credentials + the verification token hash never cross the serialization boundary. */ - protected array $hidden = ['password_hash', 'remember_token', 'email_verification_token_hash']; - - - protected ?TenantSummary $membership = null; - protected ?UserProfile $profile = null; - - - /** - * Summary of setMembership - * @param mixed $membership - * @return void - */ - public function setMembership(?TenantSummary $membership): void - { - $this->membership = $membership; - } - - /** - * Summary of getMembership - * @return TenantSummary|null - */ - public function getMembership(): ?TenantSummary - { - return $this->membership; - } - - /** - * Summary of setProfile - * @param mixed $profile - * @return void - */ - public function setProfile(?UserProfile $profile): void - { - $this->profile = $profile; - } - - /** - * Summary of getProfile - * @return UserProfile|null - */ - public function getProfile(): ?UserProfile - { - return $this->profile; - } - - /** - * Register a brand-new user. $passwordHash MUST already be a bcrypt hash - * produced by the HashingPort — never a plaintext password. - */ - public static function register( - Username $username, - Email $email, - string $passwordHash, - ): self { - self::assertBcrypt($passwordHash, 'User must be created with a bcrypt password hash.'); - - $id = UserId::generate(); - $createdAt = new \DateTimeImmutable(); - - $user = (new self())->forceFill([ - 'user_id' => $id->value(), - 'username' => $username->value(), - 'email' => $email->value(), - 'password_hash' => $passwordHash, - 'remember_token' => null, - 'version' => 1, - 'email_verified_at' => null, - 'email_verification_token_hash' => null, - 'email_verification_expires_at' => null, - 'created_at' => $createdAt, - ]); - $user->syncOriginal(); - - $user->recordEvent(new UserRegisteredDomainEvent( - userId: $id, - username: $username, - email: $email, - occurredAt: $createdAt, - )); - - - return $user; - } - - public function changeEmail(Email $email): void - { - if ($email->value() === $this->email()) { - return; - } - $this->email = $email->value(); - // A new address is unverified until reconfirmed. - $this->email_verified_at = null; - } - - public function rename(Username $username): void - { - if ($username->value() === $this->username()) { - return; - } - $this->username = $username->value(); - } - - /** Replace the stored credential with a new bcrypt hash. */ - public function changePassword(string $passwordHash): void - { - self::assertBcrypt($passwordHash, 'Password must be a bcrypt hash.'); - $this->password_hash = $passwordHash; - // Any "remember me" sessions are invalidated on credential change. - $this->remember_token = null; - } - - /** - * Mark the email confirmed. Email verification is the account's login gate - * (see canLogin / UserService::verifyCredentials) — a verified address is - * what makes the account usable. - */ - public function verifyEmail(): void - { - if ($this->emailVerifiedAt() !== null) { - return; - } - $this->email_verified_at = new \DateTimeImmutable(); - // The token hash + expiry are deliberately KEPT (not nulled): once the - // account is verified, email_verified_at is the authoritative gate, so a - // second click of the SAME (still-unexpired) link resolves to the same - // user and is reported as "already verified" instead of a confusing - // "invalid link". It confers no new power — verifyEmail() short-circuits - // above, so the token can never re-verify or mutate state — and it self- - // expires at its original TTL. Do NOT re-null these here. - } - - /** - * Arm a pending email-verification token. Stores only the SHA-256 HASH of - * the emailed token (the raw token lives only in the email) plus a hard - * expiry. Re-arming replaces any previous pending token. - */ - public function startEmailVerification(string $tokenHash, \DateTimeImmutable $expiresAt): void - { - $this->email_verification_token_hash = $tokenHash; - $this->email_verification_expires_at = $expiresAt; - } - - public function emailVerificationTokenHash(): ?string - { - $v = $this->getRawAttribute('email_verification_token_hash'); - return $v === null ? null : (string) $v; - } - - public function emailVerificationExpiresAt(): ?\DateTimeImmutable - { - return $this->getDate('email_verification_expires_at'); - } - - /** - * Record a single consolidated update event for the fields mutated so far. - * Bumps the optimistic-lock version. Returns true when something actually - * changed. Call after applying edits, before persisting. - */ - public function commitChanges(): bool - { - $changed = $this->getChanges(); - if ($changed === []) { - return false; - } - - $this->version = $this->version() + 1; - - $this->recordEvent(new UserUpdatedDomainEvent( - userId: UserId::fromString($this->id()), - changed: array_values(array_unique($changed)), - occurredAt: new \DateTimeImmutable(), - )); - - - $this->syncOriginal(); - - return true; - } - - /** Record the (soft-)deletion event. */ - public function markDeleted(): void - { - $this->recordEvent(new UserDeletedDomainEvent( - userId: UserId::fromString($this->id()), - occurredAt: new \DateTimeImmutable(), - )); - } - - - /** Store the SHA-256 of a "remember me" token (never the raw token). */ - public function setRememberTokenHash(?string $sha256Hash): void - { - $this->remember_token = $sha256Hash; - } - - private static function assertBcrypt(string $hash, string $message): void - { - // bcrypt output is always exactly 60 chars and starts with $2y$/$2a$/$2b$. - if (strlen($hash) !== 60 || !str_starts_with($hash, '$2')) { - throw new \DomainException($message); - } - } - - // ─── scalar accessors (replace the former Value-Object getters) ────────── - - public function id(): string - { - return $this->getString('user_id'); - } - public function username(): string - { - return $this->getString('username'); - } - public function email(): string - { - return $this->getString('email'); - } - public function version(): int - { - return $this->getInt('version'); - } - public function createdAt(): \DateTimeImmutable - { - return $this->getDate('created_at') ?? new \DateTimeImmutable(); - } - public function emailVerifiedAt(): ?\DateTimeImmutable - { - return $this->getDate('email_verified_at'); - } - public function isEmailVerified(): bool - { - return $this->emailVerifiedAt() !== null; - } - - /** A verified email is the login gate (replaces the old status column). */ - public function canLogin(): bool - { - return $this->emailVerifiedAt() !== null; - } - - /** Persistence-only accessors — never serialise these into a response. */ - public function passwordHash(): string - { - return $this->getString('password_hash'); - } - public function rememberToken(): ?string - { - $v = $this->getRawAttribute('remember_token'); - return $v === null ? null : (string) $v; - } -} diff --git a/plugins/User/Domain/Entities/UserNotificationPreferences.php b/plugins/User/Domain/Entities/UserNotificationPreferences.php deleted file mode 100644 index 4055870..0000000 --- a/plugins/User/Domain/Entities/UserNotificationPreferences.php +++ /dev/null @@ -1,105 +0,0 @@ - default. Mirrors the migration defaults exactly. */ - public const FLAG_DEFAULTS = [ - 'push_messages' => true, 'push_bookings' => true, 'push_payments' => true, - 'push_reminders' => true, 'push_promotions' => false, 'push_security' => true, - 'email_messages' => false, 'email_bookings' => true, 'email_payments' => true, - 'email_reminders' => false, 'email_promotions'=> false, 'email_security' => true, - 'sms_messages' => false, 'sms_bookings' => true, 'sms_payments' => true, - 'sms_reminders' => false, 'sms_promotions' => false, 'sms_security' => true, - ]; - - protected string $primaryKey = 'user_id'; - - /** Every flag column round-trips through `int-bool` (0/1 in DB, bool in PHP). */ - /** @var array */ - protected array $casts = [ - 'push_messages' => 'int-bool', 'push_bookings' => 'int-bool', 'push_payments' => 'int-bool', - 'push_reminders' => 'int-bool', 'push_promotions' => 'int-bool', 'push_security' => 'int-bool', - 'email_messages' => 'int-bool', 'email_bookings' => 'int-bool', 'email_payments' => 'int-bool', - 'email_reminders' => 'int-bool', 'email_promotions'=> 'int-bool', 'email_security' => 'int-bool', - 'sms_messages' => 'int-bool', 'sms_bookings' => 'int-bool', 'sms_payments' => 'int-bool', - 'sms_reminders' => 'int-bool', 'sms_promotions' => 'int-bool', 'sms_security' => 'int-bool', - ]; - - /** @param array $flags */ - public static function fromInput(string $userId, array $flags): self - { - if ($userId === '' || mb_strlen($userId) > 31) { - throw new \DomainException('UserNotificationPreferences requires a valid user id.'); - } - - // Start from defaults and apply only known flags — unknown keys throw. - $resolved = self::FLAG_DEFAULTS; - foreach ($flags as $key => $value) { - if (!array_key_exists($key, self::FLAG_DEFAULTS)) { - throw new \DomainException("Unknown notification flag: {$key}."); - } - $resolved[$key] = (bool) $value; - } - - $p = (new self())->forceFill(['user_id' => $userId, ...$resolved]); - $p->syncOriginal(); - - return $p; - } - - public static function defaults(string $userId): self - { - return self::fromInput($userId, []); - } - - public function userId(): string { return $this->getString('user_id'); } - - public function isEnabled(string $flag): bool - { - return array_key_exists($flag, self::FLAG_DEFAULTS) && $this->getBool($flag); - } - - /** @return array */ - public function flags(): array - { - $flags = []; - foreach (array_keys(self::FLAG_DEFAULTS) as $key) { - $flags[$key] = $this->getBool($key); - } - - return $flags; - } - - /** - * Nests the flat "channel_topic" flags into { channel: { topic: bool } } for - * the API. @return array - */ - public function toArray(bool $onlyChanged = false): array - { - $nested = []; - foreach ($this->flags() as $key => $value) { - [$channel, $topic] = explode('_', $key, 2); - $nested[$channel][$topic] = $value; - } - - return ['userId' => $this->userId(), 'flags' => $nested]; - } -} diff --git a/plugins/User/Domain/Entities/UserPreferences.php b/plugins/User/Domain/Entities/UserPreferences.php deleted file mode 100644 index 76fb7a9..0000000 --- a/plugins/User/Domain/Entities/UserPreferences.php +++ /dev/null @@ -1,120 +0,0 @@ - */ - protected array $casts = [ - 'reduce_motion' => 'int-bool', - 'larger_text' => 'int-bool', - 'high_contrast' => 'int-bool', - 'screen_reader_hints' => 'int-bool', - ]; - - public static function fromInput( - string $userId, - ?string $language, - ?string $currency, - Theme $theme, - bool $reduceMotion, - bool $largerText, - bool $highContrast, - bool $screenReaderHints, - ): self { - return self::guarded([ - 'user_id' => $userId, - 'language' => self::blankTo($language, self::DEFAULT_LANGUAGE), - 'currency' => strtoupper(self::blankTo($currency, self::DEFAULT_CURRENCY)), - 'theme' => $theme->value, - 'reduce_motion' => $reduceMotion, - 'larger_text' => $largerText, - 'high_contrast' => $highContrast, - 'screen_reader_hints' => $screenReaderHints, - ]); - } - - public static function defaults(string $userId): self - { - return self::guarded([ - 'user_id' => $userId, - 'language' => self::DEFAULT_LANGUAGE, - 'currency' => self::DEFAULT_CURRENCY, - 'theme' => Theme::System->value, - 'reduce_motion' => false, - 'larger_text' => false, - 'high_contrast' => false, - 'screen_reader_hints' => false, - ]); - } - - /** @param array $attrs Validate, then hydrate the bag. */ - private static function guarded(array $attrs): self - { - $userId = (string) $attrs['user_id']; - if ($userId === '' || mb_strlen($userId) > 31) { - throw new \DomainException('UserPreferences requires a valid user id.'); - } - if (!preg_match('/^[a-zA-Z]{2,10}(-[a-zA-Z]{2,10})?$/', (string) $attrs['language'])) { - throw new \DomainException('Language must be a 2–10 letter tag, e.g. en or en-GB.'); - } - if (!preg_match('/^[A-Z]{3}$/', (string) $attrs['currency'])) { - throw new \DomainException('Currency must be a 3-letter ISO 4217 code, e.g. UGX.'); - } - // Validate the theme is a known enum case. - Theme::from((string) $attrs['theme']); - - $p = (new self())->forceFill($attrs); - $p->syncOriginal(); - - return $p; - } - - private static function blankTo(?string $value, string $default): string - { - $value = $value === null ? '' : trim($value); - return $value === '' ? $default : $value; - } - - public function userId(): string { return $this->getString('user_id'); } - public function language(): string { return $this->getString('language'); } - public function currency(): string { return $this->getString('currency'); } - public function theme(): Theme { return Theme::from($this->getString('theme')); } - public function reduceMotion(): bool { return $this->getBool('reduce_motion'); } - public function largerText(): bool { return $this->getBool('larger_text'); } - public function highContrast(): bool { return $this->getBool('high_contrast'); } - public function screenReaderHints(): bool { return $this->getBool('screen_reader_hints'); } - - /** @return array Camel-cased API shape (not the DB shape). */ - public function toArray(bool $onlyChanged = false): array - { - return [ - 'userId' => $this->userId(), - 'language' => $this->language(), - 'currency' => $this->currency(), - 'theme' => $this->theme()->value, - 'reduceMotion' => $this->reduceMotion(), - 'largerText' => $this->largerText(), - 'highContrast' => $this->highContrast(), - 'screenReaderHints' => $this->screenReaderHints(), - ]; - } -} diff --git a/plugins/User/Domain/Entities/UserPrivacySettings.php b/plugins/User/Domain/Entities/UserPrivacySettings.php deleted file mode 100644 index d1d6c56..0000000 --- a/plugins/User/Domain/Entities/UserPrivacySettings.php +++ /dev/null @@ -1,94 +0,0 @@ - */ - protected array $casts = [ - 'show_phone' => 'int-bool', - 'show_email' => 'int-bool', - 'marketing_opt_in' => 'int-bool', - 'analytics_opt_in' => 'int-bool', - ]; - - public static function fromInput( - string $userId, - ProfileVisibility $profileVisibility, - bool $showPhone, - bool $showEmail, - bool $marketingOptIn, - bool $analyticsOptIn, - ): self { - return self::guarded([ - 'user_id' => $userId, - 'profile_visibility' => $profileVisibility->value, - 'show_phone' => $showPhone, - 'show_email' => $showEmail, - 'marketing_opt_in' => $marketingOptIn, - 'analytics_opt_in' => $analyticsOptIn, - ]); - } - - public static function defaults(string $userId): self - { - // Mirrors the migration defaults: public, phone shown, email hidden, - // marketing off, analytics on. - return self::guarded([ - 'user_id' => $userId, - 'profile_visibility' => ProfileVisibility::Public->value, - 'show_phone' => true, - 'show_email' => false, - 'marketing_opt_in' => false, - 'analytics_opt_in' => true, - ]); - } - - /** @param array $attrs Validate, then hydrate the bag. */ - private static function guarded(array $attrs): self - { - $userId = (string) $attrs['user_id']; - if ($userId === '' || mb_strlen($userId) > 31) { - throw new \DomainException('UserPrivacySettings requires a valid user id.'); - } - // Validate the visibility is a known enum case. - ProfileVisibility::from((string) $attrs['profile_visibility']); - - $s = (new self())->forceFill($attrs); - $s->syncOriginal(); - - return $s; - } - - public function userId(): string { return $this->getString('user_id'); } - public function profileVisibility(): ProfileVisibility { return ProfileVisibility::from($this->getString('profile_visibility')); } - public function showPhone(): bool { return $this->getBool('show_phone'); } - public function showEmail(): bool { return $this->getBool('show_email'); } - public function marketingOptIn(): bool { return $this->getBool('marketing_opt_in'); } - public function analyticsOptIn(): bool { return $this->getBool('analytics_opt_in'); } - - /** @return array Camel-cased API shape (not the DB shape). */ - public function toArray(bool $onlyChanged = false): array - { - return [ - 'userId' => $this->userId(), - 'profileVisibility' => $this->profileVisibility()->value, - 'showPhone' => $this->showPhone(), - 'showEmail' => $this->showEmail(), - 'marketingOptIn' => $this->marketingOptIn(), - 'analyticsOptIn' => $this->analyticsOptIn(), - ]; - } -} diff --git a/plugins/User/Domain/Entities/UserProfile.php b/plugins/User/Domain/Entities/UserProfile.php deleted file mode 100644 index de2ea60..0000000 --- a/plugins/User/Domain/Entities/UserProfile.php +++ /dev/null @@ -1,144 +0,0 @@ - $userId, - 'first_name' => self::nullIfBlank($firstName), - 'last_name' => self::nullIfBlank($lastName), - 'avatar_url' => self::nullIfBlank($avatarUrl), - 'timezone' => self::nullIfBlank($timezone) ?? self::DEFAULT_TIMEZONE, - 'locale' => self::nullIfBlank($locale) ?? self::DEFAULT_LOCALE, - 'phone' => self::nullIfBlank($phone) ?? self::DEFAULT_PHONE, - ]); - } - - /** The defaults returned when a user has no profile row yet. */ - public static function defaults(string $userId): self - { - return self::guarded([ - 'user_id' => $userId, - 'first_name' => null, - 'last_name' => null, - 'avatar_url' => null, - 'timezone' => self::DEFAULT_TIMEZONE, - 'locale' => self::DEFAULT_LOCALE, - 'phone' => self::DEFAULT_PHONE, - ]); - } - - /** @param array $attrs Validate, then hydrate the bag. */ - private static function guarded(array $attrs): self - { - $userId = (string) $attrs['user_id']; - if ($userId === '' || mb_strlen($userId) > 31) { - throw new \DomainException('UserProfile requires a valid user id.'); - } - self::guardOptional($attrs['first_name'], 80, 'First name'); - self::guardOptional($attrs['last_name'], 80, 'Last name'); - $avatarUrl = $attrs['avatar_url']; - if ($avatarUrl !== null) { - if (mb_strlen($avatarUrl) > 500 || !filter_var($avatarUrl, FILTER_VALIDATE_URL)) { - throw new \DomainException('Avatar URL must be a valid URL.'); - } - $scheme = strtolower((string) parse_url($avatarUrl, PHP_URL_SCHEME)); - if ($scheme !== 'http' && $scheme !== 'https') { - throw new \DomainException('Avatar URL must use http or https.'); - } - } - if (!in_array($attrs['timezone'], timezone_identifiers_list(), true)) { - throw new \DomainException('Unknown timezone.'); - } - if (!preg_match('/^[a-z]{2}_[A-Z]{2}$/', (string) $attrs['locale'])) { - throw new \DomainException('Locale must be in ll_CC form, e.g. en_US.'); - } - if (!preg_match('/^\+?[0-9]{7,15}$/', (string) $attrs['phone'])) { - throw new \DomainException('Phone must be 7–15 digits (optional leading +).'); - } - - $p = (new self())->forceFill($attrs); - $p->syncOriginal(); - - return $p; - } - - private static function guardOptional(?string $value, int $max, string $label): void - { - if ($value !== null && mb_strlen($value) > $max) { - throw new \DomainException("{$label} cannot exceed {$max} characters."); - } - } - - private static function nullIfBlank(?string $value): ?string - { - $value = $value === null ? null : trim($value); - return ($value === null || $value === '') ? null : $value; - } - - public function userId(): string { return $this->getString('user_id'); } - public function firstName(): ?string { return $this->nullable('first_name'); } - public function lastName(): ?string { return $this->nullable('last_name'); } - public function avatarUrl(): ?string { return $this->nullable('avatar_url'); } - public function timezone(): string { return $this->getString('timezone'); } - public function locale(): string { return $this->getString('locale'); } - public function phone(): string { return $this->getString('phone'); } - - public function fullName(): string - { - $first = trim((string) $this->firstName()); - $last = trim((string) $this->lastName()); - return trim("{$first} {$last}"); - } - private function nullable(string $key): ?string - { - $v = $this->getRawAttribute($key); - return $v === null ? null : (string) $v; - } - - /** @return array Camel-cased API shape (not the DB shape). */ - public function toArray(bool $onlyChanged = false): array - { - return [ - 'userId' => $this->userId(), - 'firstName' => $this->firstName(), - 'lastName' => $this->lastName(), - 'avatarUrl' => $this->avatarUrl(), - 'timezone' => $this->timezone(), - 'locale' => $this->locale(), - 'phone' => $this->phone(), - ]; - } -} diff --git a/plugins/User/Domain/Events/UserDeletedDomainEvent.php b/plugins/User/Domain/Events/UserDeletedDomainEvent.php deleted file mode 100644 index ae539b6..0000000 --- a/plugins/User/Domain/Events/UserDeletedDomainEvent.php +++ /dev/null @@ -1,19 +0,0 @@ - $changed */ - public function __construct( - public UserId $userId, - public array $changed, - public \DateTimeImmutable $occurredAt, - ) {} -} diff --git a/plugins/User/Domain/Exceptions/DuplicateUserException.php b/plugins/User/Domain/Exceptions/DuplicateUserException.php deleted file mode 100644 index d406b76..0000000 --- a/plugins/User/Domain/Exceptions/DuplicateUserException.php +++ /dev/null @@ -1,25 +0,0 @@ - $fields */ - public function __construct(public readonly array $fields = ['username', 'email']) - { - parent::__construct( - 'A user with that username or email already exists.', - layer: 'domain.user', - context: ['fields' => $fields], - ); - } -} diff --git a/plugins/User/Domain/ValueObjects/Email.php b/plugins/User/Domain/ValueObjects/Email.php deleted file mode 100644 index 66ab2ee..0000000 --- a/plugins/User/Domain/ValueObjects/Email.php +++ /dev/null @@ -1,32 +0,0 @@ - 150) { - throw new \DomainException('Email must be 150 characters or fewer.'); - } - if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) { - throw new \DomainException('Email is not a valid address.'); - } - } - - public static function fromString(string $value): self - { - // Lower-case the address for stable uniqueness (uniq_email). - return new self(mb_strtolower(trim($value))); - } - - public function value(): string - { - return $this->value; - } -} diff --git a/plugins/User/Domain/ValueObjects/FeedbackCategory.php b/plugins/User/Domain/ValueObjects/FeedbackCategory.php deleted file mode 100644 index 105cfbf..0000000 --- a/plugins/User/Domain/ValueObjects/FeedbackCategory.php +++ /dev/null @@ -1,38 +0,0 @@ -value; - } -} diff --git a/plugins/User/Domain/ValueObjects/FeedbackMessage.php b/plugins/User/Domain/ValueObjects/FeedbackMessage.php deleted file mode 100644 index ec18e65..0000000 --- a/plugins/User/Domain/ValueObjects/FeedbackMessage.php +++ /dev/null @@ -1,43 +0,0 @@ -value); - if ($len < self::MIN) { - throw new \DomainException('Feedback message cannot be empty.'); - } - if ($len > self::MAX) { - throw new \DomainException('Feedback message cannot exceed ' . self::MAX . ' characters.'); - } - } - - public static function fromString(string $value): self - { - // Strip control chars except tab (\x09) and newline (\x0A). - $clean = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', trim($value)) ?? ''; - - return new self($clean); - } - - public function value(): string - { - return $this->value; - } -} diff --git a/plugins/User/Domain/ValueObjects/FeedbackRating.php b/plugins/User/Domain/ValueObjects/FeedbackRating.php deleted file mode 100644 index 58d9003..0000000 --- a/plugins/User/Domain/ValueObjects/FeedbackRating.php +++ /dev/null @@ -1,59 +0,0 @@ - self::MAX) { - throw new \DomainException('Rating must be between 1 and 5.'); - } - } - - public static function of(int $value): self - { - return new self($value); - } - - /** - * null/'' → no rating. Accepts an int, an integer-valued float (4.0) or a - * digit string; rejects fractional floats, arrays and non-numeric strings. - * Typed `mixed` because it receives raw request input (a JSON number may - * decode as float) — a narrow union would TypeError under strict_types - * instead of yielding a clean validation error. - */ - public static function fromNullable(mixed $value): ?self - { - if ($value === null || $value === '') { - return null; - } - if (is_int($value)) { - return new self($value); - } - if (is_float($value) && floor($value) === $value) { - return new self((int) $value); - } - if (is_string($value) && ctype_digit($value)) { - return new self((int) $value); - } - - throw new \DomainException('Rating must be a whole number 1–5.'); - } - - public function value(): int - { - return $this->value; - } -} diff --git a/plugins/User/Domain/ValueObjects/FeedbackStatus.php b/plugins/User/Domain/ValueObjects/FeedbackStatus.php deleted file mode 100644 index 7c19d13..0000000 --- a/plugins/User/Domain/ValueObjects/FeedbackStatus.php +++ /dev/null @@ -1,39 +0,0 @@ -rank() > $this->rank(); - } - - private function rank(): int - { - return match ($this) { - self::Received => 0, - self::Acknowledged => 1, - self::Resolved => 2, - }; - } -} diff --git a/plugins/User/Domain/ValueObjects/PasswordPolicy.php b/plugins/User/Domain/ValueObjects/PasswordPolicy.php deleted file mode 100644 index ef3636b..0000000 --- a/plugins/User/Domain/ValueObjects/PasswordPolicy.php +++ /dev/null @@ -1,67 +0,0 @@ - - */ - public static function validate(string $password): array - { - $bytes = strlen($password); - - if ($bytes < self::MIN) { - return ['password' => 'Password must be at least ' . self::MIN . ' characters.']; - } - if ($bytes > self::MAX) { - return ['password' => 'Password must be ' . self::MAX . ' bytes or fewer.']; - } - - $classes = - (int) (bool) preg_match('/[a-z]/', $password) - + (int) (bool) preg_match('/[A-Z]/', $password) - + (int) (bool) preg_match('/\d/', $password) - + (int) (bool) preg_match('/[^A-Za-z0-9]/', $password); - - if ($classes < 3) { - return ['password' => 'Use at least three of: lowercase, uppercase, digits, symbols.']; - } - - if (in_array(mb_strtolower($password), self::COMMON, true)) { - return ['password' => 'This password is too common.']; - } - - return []; - } -} diff --git a/plugins/User/Domain/ValueObjects/ProfileVisibility.php b/plugins/User/Domain/ValueObjects/ProfileVisibility.php deleted file mode 100644 index 7b4b5b7..0000000 --- a/plugins/User/Domain/ValueObjects/ProfileVisibility.php +++ /dev/null @@ -1,22 +0,0 @@ - 16 base32 indices forming the random component. */ - private static array $lastRand = []; - - public static function generate(): string - { - $alphabet = self::ALPHABET; - $time = (int) (microtime(true) * 1000); - - if ($time === self::$lastTime && self::$lastRand !== []) { - for ($i = 15; $i >= 0; $i--) { - if (self::$lastRand[$i] < 31) { - self::$lastRand[$i]++; - break; - } - self::$lastRand[$i] = 0; - } - } else { - self::$lastTime = $time; - self::$lastRand = []; - for ($i = 0; $i < 16; $i++) { - self::$lastRand[$i] = random_int(0, 31); - } - } - - $t = $time; - $ulid = ''; - for ($i = 9; $i >= 0; $i--) { - $ulid = $alphabet[$t % 32] . $ulid; - $t = intdiv($t, 32); - } - foreach (self::$lastRand as $idx) { - $ulid .= $alphabet[$idx]; - } - - return $ulid; - } -} diff --git a/plugins/User/Domain/ValueObjects/UserId.php b/plugins/User/Domain/ValueObjects/UserId.php deleted file mode 100644 index 105b5d1..0000000 --- a/plugins/User/Domain/ValueObjects/UserId.php +++ /dev/null @@ -1,43 +0,0 @@ - 31) { - throw new \DomainException('UserId must be 1-31 characters.'); - } - } - - /** Generate a 26-char monotonic Crockford ULID (time-ordered, fits char(31)). */ - public static function generate(): self - { - return new self(Ulid::generate()); - } - - public static function fromString(string $value): self - { - return new self($value); - } - - public function value(): string - { - return $this->value; - } - - public function equals(self $other): bool - { - return $this->value === $other->value; - } -} diff --git a/plugins/User/Domain/ValueObjects/Username.php b/plugins/User/Domain/ValueObjects/Username.php deleted file mode 100644 index 62392c4..0000000 --- a/plugins/User/Domain/ValueObjects/Username.php +++ /dev/null @@ -1,35 +0,0 @@ - 50) { - throw new \DomainException('Username must be between 5 and 50 characters.'); - } - if (!preg_match('/^[A-Za-z0-9._-]+$/', $value)) { - throw new \DomainException('Username may only contain letters, digits, dot, underscore and hyphen.'); - } - } - - public static function fromString(string $value): self - { - return new self(trim($value)); - } - - public function value(): string - { - return $this->value; - } -} diff --git a/plugins/User/Infrastructure/Audit/AuditLogger.php b/plugins/User/Infrastructure/Audit/AuditLogger.php deleted file mode 100644 index 971ddd4..0000000 --- a/plugins/User/Infrastructure/Audit/AuditLogger.php +++ /dev/null @@ -1,106 +0,0 @@ -sink = $sink ?? static fn(string $line) => error_log($line); - } - - /** @param array $context */ - public function record(string $action, array $context = []): void - { - $occurredAt = (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339); - - $entry = json_encode([ - 'source' => 'user_audit', - 'action' => $action, - 'actor' => $this->actorId, - 'context' => $context, - 'timestamp' => $occurredAt, - ], JSON_UNESCAPED_SLASHES); - - if ($entry !== false) { - ($this->sink)($entry); - } - - $this->persist($action, $context, $occurredAt); - } - - /** - * Persist to the shared `audit_log` table. Best-effort: any failure is - * swallowed (already captured in the log line) so auditing never aborts the - * audited action. `userId` in context maps to the user_id column; everything - * else is kept in the JSON `meta` column. - * - * @param array $context - */ - private function persist(string $action, array $context, string $occurredAt): void - { - if ($this->db === null) { - return; - } - - $userId = isset($context['userId']) ? (string) $context['userId'] : ($this->actorId ?: null); - $ip = isset($context['ip']) ? (string) $context['ip'] : null; - - $meta = $context; - unset($meta['userId'], $meta['ip']); - $metaJson = $meta === [] ? null : json_encode($meta, JSON_UNESCAPED_SLASHES); - - try { - $this->db->execute( - 'INSERT INTO audit_log (event_id, user_id, tenant_id, action, ip, meta, occurred_at) - VALUES (:event_id, :user_id, :tenant_id, :action, :ip, :meta, :occurred_at)', - [ - 'event_id' => Ulid::generate(), - 'user_id' => $userId, - 'tenant_id' => ($this->tenantId ?? '') !== '' ? $this->tenantId : null, - 'action' => $action, - 'ip' => $ip, - 'meta' => $metaJson === false ? null : $metaJson, - 'occurred_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), - ], - ); - } catch (\Throwable) { - // Best-effort — the log line above is the durable fallback. - } - } -} diff --git a/plugins/User/Infrastructure/Cli/RelayUserOutboxCommand.php b/plugins/User/Infrastructure/Cli/RelayUserOutboxCommand.php deleted file mode 100644 index 680a312..0000000 --- a/plugins/User/Infrastructure/Cli/RelayUserOutboxCommand.php +++ /dev/null @@ -1,41 +0,0 @@ -name = 'user:outbox:relay'; - $this->description = 'Relay pending user integration events from the outbox to the EventBus'; - $this->addOption('limit', 'l', 'Max events to relay this run', acceptsValue: true, default: 100); - } - - protected function handle(): int - { - $limit = (int) $this->option('limit', 100); - $count = $this->relay->relayBatch($limit); - - $this->success("Relayed {$count} event(s)."); - - return self::SUCCESS; - } -} diff --git a/plugins/User/Infrastructure/Gateways/NullBreachChecker.php b/plugins/User/Infrastructure/Gateways/NullBreachChecker.php deleted file mode 100644 index 9e29c58..0000000 --- a/plugins/User/Infrastructure/Gateways/NullBreachChecker.php +++ /dev/null @@ -1,22 +0,0 @@ -http->request('GET', self::ENDPOINT . $prefix, [ - 'headers' => ['Add-Padding' => 'true'], - 'timeout' => 3, - 'connect_timeout' => 2, - ]); - } catch (\Throwable) { - return false; // fail open - } - - if (!$response->ok()) { - return false; // fail open - } - - foreach (preg_split('/\r\n|\r|\n/', $response->body()) ?: [] as $line) { - $parts = explode(':', trim($line), 2); - if (count($parts) !== 2) { - continue; - } - // Padding rows have a count of 0 — ignore them. - if (strcasecmp($parts[0], $suffix) === 0) { - return ((int) $parts[1]) >= $this->threshold; - } - } - - return false; - } -} diff --git a/plugins/User/Infrastructure/Http/.gitkeep b/plugins/User/Infrastructure/Http/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/plugins/User/Infrastructure/Http/Controllers/FeedbackController.php b/plugins/User/Infrastructure/Http/Controllers/FeedbackController.php deleted file mode 100644 index 0e1b6ce..0000000 --- a/plugins/User/Infrastructure/Http/Controllers/FeedbackController.php +++ /dev/null @@ -1,57 +0,0 @@ -resolveRequest(). - */ -final class FeedbackController extends ApiController -{ - public function __construct( - private readonly FeedbackService $feedback, - ) {} - - public function submit(): Response - { - $dto = SubmitFeedbackDTO::fromRequest($this->resolveRequest()); - return $this->created($this->feedback->submit($dto)->toArray()); - } - - public function show(string $id): Response - { - return $this->okOrNotFound( - $this->feedback->find($id)?->toArray(), - "Feedback [{$id}] not found.", - ); - } - - public function index(): Response - { - $page = $this->feedback->list(ListFeedbackQuery::fromRequest($this->resolveRequest())); - - return Response::json([ - 'data' => array_map(static fn($f) => $f->toArray(), $page->items), - 'meta' => $page->meta(), - ]); - } - - public function updateStatus(string $id): Response - { - $status = (string) $this->resolveRequest()->input('status', ''); - $entry = $this->feedback->updateStatus($id, $status); - - return $this->okOrNotFound($entry?->toArray(), "Feedback [{$id}] not found."); - } -} diff --git a/plugins/User/Infrastructure/Http/Controllers/UserController.php b/plugins/User/Infrastructure/Http/Controllers/UserController.php deleted file mode 100644 index ec03452..0000000 --- a/plugins/User/Infrastructure/Http/Controllers/UserController.php +++ /dev/null @@ -1,257 +0,0 @@ -request / $this->resolveRequest(). - */ -final class UserController extends ApiController -{ - public function __construct( - private readonly UserServiceContract $users, - /** Optional — when a MailPort is bound, signup queues a verification email. */ - private readonly ?MailPort $mailer = null, - ) {} - - public function index(): Response - { - $query = ListUsersQuery::fromRequest($this->resolveRequest()); - $page = $this->users->list($query); - - return Response::json([ - 'data' => array_map(static fn($u) => $u->toArray(), $page->items), - 'meta' => $page->meta(), - ]); - } - - /** - * PUBLIC self-signup. Returns 202 with a non-revealing body — no id, email - * or verification state ever goes back to an unauthenticated registrant. - * The verification token is emailed out-of-band (see the mailer seam below), - * NOT echoed in the response. Location points at the "check your email" flow. - */ - public function register(): Response - { - $dto = RegisterUserDTO::fromRequest($this->resolveRequest()); - $token = $this->users->registerPublic($dto); - $this->queueVerificationEmail($dto->email->value(), $token); - - return Response::json(['status' => 'pending_verification'], 202) - ->withHeader('Location', '/verify-email'); - } - - /** - * Queue the verification email. The token stays server-side (never in the - * HTTP response); the emailed link points at the project's /verify-email - * page, which POSTs the token to /ajx/users/verify. No-ops when no MailPort - * is bound; a mail failure NEVER breaks signup (the user can request a resend). - */ - private function queueVerificationEmail(string $email, string $token): void - { - if ($this->mailer === null) { - return; - } - try { - $url = $this->resolveRequest()->site()->to('verify-email', ['token' => $token]); - $this->mailer->queue($email, 'Verify your email address', 'user::emails/verify', ['url' => $url]); - } catch (\Throwable) { - // Best-effort — signup already succeeded; swallow mail-transport faults. - } - } - - /** - * ADMIN create — authenticated + permission-gated at the route (auth filter) - * and in the service. Returns the FULL created record so it can be dropped - * straight into the admin table; location points at the admin verify view. - */ - public function adminCreate(): Response - { - $result = $this->users->register(RegisterUserDTO::fromRequest($this->resolveRequest())); - - return $this->created( - $result->toArray(), - location: "/admin/users/{$result->id}/verify", - ); - } - - /** - * PUBLIC email confirmation — the unauthenticated link a registrant clicks. - * Token in the request body/query; no identity required. Always a generic - * response so a bad/expired token reveals nothing. - */ - /** Cookie that binds a resend to the email of a just-attempted expired token. */ - private const RESEND_BIND_COOKIE = 'vrf_bind'; - private const RESEND_BIND_MINUTES = 30; - - /** Per-email resend cap: max sends within the window (defence-in-depth). */ - private const RESEND_MAX_PER_EMAIL = 3; - private const RESEND_EMAIL_WINDOW = 3600; - - public function verifyEmailByToken(): Response - { - $token = (string) $this->resolveRequest()->input('token', ''); - - $result = $this->users->verifyEmailByToken($token); - - return match ($result->status) { - UserServiceContract::VERIFY_OK => Response::json(['status' => 'verified']), - // Disclosed ONLY because a valid token proves inbox control. - UserServiceContract::VERIFY_ALREADY => Response::json([ - 'status' => 'already_verified', - 'message' => 'Your email is already verified — you can sign in.', - ]), - // Correct token, past its TTL. Distinct code (`token_expired`) so the - // client can surface the resend option directly. Safe to disclose: - // only a real, matched token reaches this branch. We ALSO bind this - // browser to the matched email (keyed HMAC, encrypted HttpOnly - // cookie) so the follow-up resend can only target THIS address. - UserServiceContract::VERIFY_EXPIRED => $this->expiredResponse($result->email), - default => $this->unprocessable( - ['token' => 'This verification link is invalid or has expired.'], - ), - }; - } - - /** 422 for an expired-but-matched token + the resend-binding cookie. */ - private function expiredResponse(?string $email): Response - { - if ($email !== null && $email !== '') { - $this->queueCookie(self::RESEND_BIND_COOKIE, $this->bindHash($email), self::RESEND_BIND_MINUTES); - } - - return Response::json([ - 'error' => [ - 'code' => 'token_expired', - 'message' => 'This verification link has expired. Request a new one below.', - 'fields' => ['token' => 'This verification link has expired.'], - ], - ], 422); - } - - /** - * PUBLIC resend of the verification email — the recovery path when a link is - * expired/invalid. Enumeration-safe: the service returns a token only for an - * unverified account, and this endpoint ALWAYS answers with the same generic - * 202 (never revealing whether the email is registered or already verified). - * - * EXTRA LAYER: if this browser recently presented an expired token (the - * `vrf_bind` cookie is set), the submitted email MUST match the one bound to - * that attempt — otherwise the request is blocked. This stops a browser that - * proved control of address A from firing resends at an arbitrary address B. - */ - public function resendVerification(): Response - { - $email = trim((string) $this->resolveRequest()->input('email', '')); - - $bound = $this->cookie(self::RESEND_BIND_COOKIE); - - if ($bound !== null && $bound !== '' - && !hash_equals($bound, $this->bindHash($email))) { - // Bound to a different address than the one submitted — refuse, and - // give nothing away about either address. - return Response::forbidden('This email does not match your pending verification request.'); - } - - // Per-EMAIL send cap (defence-in-depth over the per-IP route throttle): - // a victim's inbox can't be flooded even from many IPs. Over quota, we - // silently skip the send but still return the same generic 202 — no - // observable difference, so enumeration-safety holds. - if ($email !== '' && $this->withinResendQuota($email)) { - - $token = $this->users->resendVerification($email); - if ($token !== null) { - $this->queueVerificationEmail($email, $token); - } - } - - // One-shot binding: clear it so the cookie can't be replayed. - $this->forgetCookie(self::RESEND_BIND_COOKIE); - - return Response::json([ - 'status' => 'pending_verification', - 'message' => 'If that address needs verifying, we\'ve sent a new link. Check your inbox.', - 'token' => $token, // never echo the token back to the client - ], 202); - } - - /** - * True while the submitted email is under its resend quota (default 3 per - * hour), incrementing the counter as a side effect. Fails OPEN when no cache - * is available — the per-IP route throttle still applies. - */ - private function withinResendQuota(string $email): bool - { - $container = $this->resolveRequest()->container(); - if ($container === null || !$container->has(CachePort::class)) { - return true; - } - $cache = $container->make(CachePort::class); - if (!$cache instanceof CachePort) { - return true; - } - - $key = 'vrf_send_' . hash('sha256', strtolower($email)); - $count = (int) ($cache->get($key) ?? 0); - if ($count >= self::RESEND_MAX_PER_EMAIL) { - return false; - } - - $count === 0 - ? $cache->set($key, 1, self::RESEND_EMAIL_WINDOW) - : $cache->increment($key); - - return true; - } - - /** - * Keyed HMAC of a normalised email. Stored (encrypted) in the binding cookie - * so the raw address is never written to the client, and compared in - * constant time on resend. - */ - private function bindHash(string $email): string - { - return hash_hmac('sha256', strtolower(trim($email)), (string) env('APP_KEY')); - } - - public function show(string $id): Response - { - return $this->okOrNotFound( - $this->users->find($id)?->toArray(), - "User [{$id}] not found.", - ); - } - - public function update(string $id): Response - { - $user = $this->users->update($id, UpdateUserDTO::fromRequest($this->resolveRequest())); - return $this->okOrNotFound($user?->toArray(), "User [{$id}] not found."); - } - - public function verifyEmail(string $id): Response - { - $user = $this->users->verifyEmail($id, VerifyEmailDTO::fromRequest($this->resolveRequest())); - return $this->okOrNotFound($user?->toArray(), "User [{$id}] not found."); - } - - public function destroy(string $id): Response - { - return $this->users->delete($id) - ? $this->noContent() - : $this->notFound("User [{$id}] not found."); - } -} diff --git a/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php b/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php deleted file mode 100644 index 04b2821..0000000 --- a/plugins/User/Infrastructure/Http/Controllers/UserFlowController.php +++ /dev/null @@ -1,119 +0,0 @@ - plus noindex,nofollow, with no graph cost. Both helpers - * no-op ('') on Pageflow XHR navigations, so SPA hops pay nothing. - */ -final class UserFlowController -{ - use InteractsWithGraphSeo; - - public function __construct( - private readonly PageflowResponder $pageflow, - private readonly UserServiceContract $users, - ) { - } - - /** Admin: paginated user list → component "User/Index". */ - public function adminIndex(Request $request): Response - { - $page = $this->users->list(ListUsersQuery::fromRequest($request)); - - return $this->pageflow->render($request, 'User/Index', 'admin', [ - 'users' => array_map([$this, 'row'], $page->items), - 'hasMore' => $page->hasMore, - 'nextCursor' => $page->nextCursor(), - 'seoHead' => $this->seoPrivate('Users', request: $request), - ]); - } - - /** Admin: single user → component "User/Show". */ - public function adminShow(Request $request, string $id): Response - { - $user = $this->users->find($id); - - return $this->pageflow->render($request, 'User/Show', 'admin', [ - 'user' => $user !== null ? $this->row($user) : null, - 'seoHead' => $this->seoPrivate($user !== null ? "User {$user->username}" : 'User', request: $request), - ]); - } - - /** Public: registration form → component "User/Register". */ - public function register(Request $request): Response - { - return $this->pageflow->render($request, 'User/Register', 'admin', [ - 'seoHead' => $this->seoFor( - title: 'Create your account', - description: 'Sign up in seconds — create a free account and get instant access.', - path: '/register', - breadcrumbs: [['Home', '/'], ['Create your account', '/register']], - request: $request, - ), - ]); - } - - /** - * Public: email-verification landing → component "User/VerifyEmail". The - * emailed link points here (`/verify-email?token=...`); the token is passed - * as a prop so the page can prefill and POST it to /ajx/users/verify. - * noindex — a token-bearing URL must never enter a search index. - */ - public function verifyEmail(Request $request): Response - { - return $this->pageflow->render($request, 'User/VerifyEmail', 'admin', [ - 'token' => (string) $request->query('token', ''), - 'seoHead' => $this->seoPrivate('Verify your email', request: $request), - ]); - } - - /** Public: the signed-in user's own profile → component "User/Profile". */ - public function profile(Request $request): Response - { - $identity = $request->identity(); - $user = $identity !== null && !$identity->isGuest() - ? $this->users->find($identity->userId) - : null; - - return $this->pageflow->render($request, 'User/Profile', 'admin', [ - 'user' => $user !== null ? $this->row($user) : null, - 'seoHead' => $this->seoPrivate('Your profile', request: $request), - ]); - } - - /** Map a UserDTO to the plain props the client expects (no secrets). */ - private function row(object $u): array - { - return [ - 'id' => $u->id, - 'username' => $u->username, - 'email' => $u->email, - 'emailVerified' => $u->emailVerified, - 'createdAt' => $u->createdAt, - ]; - } -} diff --git a/plugins/User/Infrastructure/Http/Controllers/UserPageController.php b/plugins/User/Infrastructure/Http/Controllers/UserPageController.php deleted file mode 100644 index fa92e92..0000000 --- a/plugins/User/Infrastructure/Http/Controllers/UserPageController.php +++ /dev/null @@ -1,81 +0,0 @@ - tag, a hidden - * form field, and send it back in the X-CSRF-Token header on every unsafe - * request so the SecurityGateway accepts the mutation. - */ -final class UserPageController extends ViewController -{ - use InteractsWithGraphSeo; - - protected const API_BASE = '/ajx/users'; - - public function index(): Response - { - return $this->page('user::users/index', ['title' => 'Users']); - } - - public function create(): Response - { - return $this->page('user::users/create', ['title' => 'Create account']); - } - - public function show(string $id): Response - { - return $this->page('user::users/show', ['title' => 'User detail', 'userId' => $id]); - } - - public function edit(string $id): Response - { - return $this->page('user::users/edit', ['title' => 'Edit user', 'userId' => $id]); - } - - /** - * Email-verification landing page. The link emailed on public signup points - * here (`GET /verify-email?token=...`); the page prefills the token from the - * query string (if present) and POSTs it to `/ajx/users/verify`. Also usable - * as a manual "paste your token" form when the link was not followed. - */ - public function verify(): Response - { - $token = (string) $this->resolveRequest()->query('token', ''); - - return $this->page('user::account/verify', ['title' => 'Verify email', 'token' => $token]); - } - - /** Account settings demo — read/update CRUD for the 4 settings resources. */ - public function settings(): Response - { - // These pages call several /ajx/* resources, so the base is just /ajx. - return $this->page('user::account/settings', ['title' => 'Account settings'], '/ajx'); - } - - /** @param array $data */ - private function page(string $view, array $data, string $apiBase = self::API_BASE): Response - { - // The JSON endpoints live under /ajx/...; hand the base to the layout - // so the AJAX UI calls the real routes instead of the view's default. - // Every page here is a private app shell (admin CRUD, token landing, - // account settings), so the layout gets a seoPrivate() head: a branded - // plus noindex,nofollow — these URLs must never be indexed. - return $this->view($view, $data + [ - 'apiBase' => $apiBase, - 'seoHead' => $this->seoPrivate((string) ($data['title'] ?? 'Users')), - ], 'user::layouts/app'); - } -} diff --git a/plugins/User/Infrastructure/Http/Controllers/UserSettingsController.php b/plugins/User/Infrastructure/Http/Controllers/UserSettingsController.php deleted file mode 100644 index 02d6fb6..0000000 --- a/plugins/User/Infrastructure/Http/Controllers/UserSettingsController.php +++ /dev/null @@ -1,69 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User\Infrastructure\Http\Controllers; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Response; -use Plugins\User\API\DTOs\UpdateNotificationPreferencesDTO; -use Plugins\User\API\DTOs\UpdatePreferencesDTO; -use Plugins\User\API\DTOs\UpdatePrivacyDTO; -use Plugins\User\API\DTOs\UpdateProfileDTO; -use Plugins\User\Application\Services\UserSettingsService; -use Project\Http\Controllers\ApiController; - -/** - * Thin HTTP boundary for the authenticated user's settings (self-scoped). One - * controller for the four settings resources; each action is DTO → service → - * Response and serialises the returned entity via toArray(). - */ -final class UserSettingsController extends ApiController -{ - public function __construct( - private readonly UserSettingsService $settings, - ) {} - - public function showProfile(): Response - { - return $this->ok($this->settings->getProfile()->toArray()); - } - - public function updateProfile(): Response - { - $dto = UpdateProfileDTO::fromRequest($this->resolveRequest()); - return $this->ok($this->settings->updateProfile($dto)->toArray()); - } - - public function showPreferences(): Response - { - return $this->ok($this->settings->getPreferences()->toArray()); - } - - public function updatePreferences(): Response - { - $dto = UpdatePreferencesDTO::fromRequest($this->resolveRequest()); - return $this->ok($this->settings->updatePreferences($dto)->toArray()); - } - - public function showPrivacy(): Response - { - return $this->ok($this->settings->getPrivacy()->toArray()); - } - - public function updatePrivacy(): Response - { - $dto = UpdatePrivacyDTO::fromRequest($this->resolveRequest()); - return $this->ok($this->settings->updatePrivacy($dto)->toArray()); - } - - public function showNotifications(): Response - { - return $this->ok($this->settings->getNotifications()->toArray()); - } - - public function updateNotifications(): Response - { - $dto = UpdateNotificationPreferencesDTO::fromRequest($this->resolveRequest()); - return $this->ok($this->settings->updateNotifications($dto)->toArray()); - } -} diff --git a/plugins/User/Infrastructure/Listeners/ProvisionTenantProfileListener.php b/plugins/User/Infrastructure/Listeners/ProvisionTenantProfileListener.php deleted file mode 100644 index 9cf1ee4..0000000 --- a/plugins/User/Infrastructure/Listeners/ProvisionTenantProfileListener.php +++ /dev/null @@ -1,67 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User\Infrastructure\Listeners; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Events\Contracts\EventListenerContract; -use AlfacodeTeam\PhpServicePlatform\Kernel\Events\Contracts\IntegrationEventContract; -use Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract; -use Plugins\User\Application\Services\TenantProfileProvisioner; -use Plugins\User\Infrastructure\Persistence\UserSettingsRepository; - -/** - * Creates the per-tenant user_profiles row from a user.registered event. - * - * WHY A LISTENER (not part of register): identity lives in the CENTRAL `users` - * table; the profile lives in the per-tenant `user_profiles` table — a DIFFERENT - * database. The two cannot share one transaction, so the profile is written - * asynchronously, eventually-consistent, at-least-once (the outbox re-delivers - * on failure; the upsert on user_id makes replay idempotent). - * - * ACCESS RULE: the listener only ORCHESTRATES. The tenant connection is known - * solely at relay time, so the listener acts as the composition point — but it - * never touches a DatabasePort. It resolves the tenant connection, wires a - * repository against it and hands that to the provisioner service: - * - * listener → TenantProfileProvisioner (service) → UserSettingsRepository → DatabasePort - * - * WIRING: the EventBus resolves this listener from the CoreContainer. To actually - * write, the PROJECT must bind it WITH a TenantConnectionResolverContract in - * bootstrap (same pattern as the SEO IndexNow listener). Left unbound it is - * constructed argument-free and safely no-ops — a signup with no profile block - * and a platform with no Tenancy simply skip it. - */ -final class ProvisionTenantProfileListener implements EventListenerContract -{ - /** Only these primitive columns are trusted off the event. */ - private const ALLOWED = ['first_name', 'last_name', 'phone', 'timezone', 'locale']; - - public function __construct( - private readonly ?TenantConnectionResolverContract $connections = null, - ) {} - - public function handle(IntegrationEventContract $event): void - { - if ($event->name() !== 'user.registered') { - return; - } - - $payload = $event->payload(); - $tenantId = (string) ($payload['tenantId'] ?? ''); - $userId = (string) ($payload['userId'] ?? ''); - $profile = $payload['profile'] ?? []; - - // No tenant, no profile, or no resolver bound → nothing to persist. - if ($this->connections === null || $tenantId === '' || $userId === '' || !is_array($profile) || $profile === []) { - return; - } - - // Compose service → repository against the ORIGIN tenant's connection. - // The listener never calls the DatabasePort itself — the repository does. - $repository = new UserSettingsRepository($this->connections->for($tenantId)); - $provisioner = new TenantProfileProvisioner($repository); - - $provisioner->provision($userId, array_intersect_key($profile, array_flip(self::ALLOWED))); - } -} diff --git a/plugins/User/Infrastructure/Outbox/OutboxRelay.php b/plugins/User/Infrastructure/Outbox/OutboxRelay.php deleted file mode 100644 index 032d055..0000000 --- a/plugins/User/Infrastructure/Outbox/OutboxRelay.php +++ /dev/null @@ -1,90 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User\Infrastructure\Outbox; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Events\EventBus; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\User\API\IntegrationEvents\GenericIntegrationEvent; - -/** - * Drains pending rows from `user_outbox` to the EventBus. - * - * Delivery is AT-LEAST-ONCE: an event is dispatched first, then marked - * dispatched. If the process dies between the two, the row stays pending and is - * re-sent on the next run — consumers must dedupe on the event_id (the UUID is - * the idempotency key). After MAX_ATTEMPTS failures a row is parked as failed. - */ -final class OutboxRelay -{ - private const MAX_ATTEMPTS = 10; - - public function __construct( - private readonly DatabasePort $db, - private readonly EventBus $eventBus, - ) {} - - /** Relay up to $limit pending events. Returns the number dispatched. */ - public function relayBatch(int $limit = 100): int - { - $rows = $this->pending($limit); - $dispatched = 0; - - foreach ($rows as $row) { - try { - $payload = json_decode((string) $row['payload'], true, 512, JSON_THROW_ON_ERROR); - - $this->eventBus->dispatch(new GenericIntegrationEvent( - name: (string) $row['event_name'], - version: (string) $row['event_version'], - payload: is_array($payload) ? $payload : [], - )); - - $this->markDispatched((int) $row['id']); - $dispatched++; - } catch (\Throwable $e) { - $this->markFailed((int) $row['id'], (int) $row['attempts'] + 1, $e->getMessage()); - } - } - - return $dispatched; - } - - /** @return list<array<string,mixed>> */ - private function pending(int $limit): array - { - try { - return $this->db->query( - 'SELECT id, event_name, event_version, payload, attempts - FROM user_outbox - WHERE status = 0 - ORDER BY occurred_at ASC, id ASC - LIMIT :limit', - ['limit' => max(1, $limit)], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to read outbox.', layer: 'repository.user.outbox', previous: $e); - } - } - - private function markDispatched(int $id): void - { - $this->db->execute( - 'UPDATE user_outbox SET status = 1, dispatched_at = :now, attempts = attempts + 1 - WHERE id = :id AND status = 0', - ['now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), 'id' => $id], - ); - } - - private function markFailed(int $id, int $attempts, string $error): void - { - $status = $attempts >= self::MAX_ATTEMPTS ? 2 : 0; // park as failed, else retry next run - $this->db->execute( - 'UPDATE user_outbox SET status = :status, attempts = :attempts, last_error = :err - WHERE id = :id', - ['status' => $status, 'attempts' => $attempts, 'err' => mb_substr($error, 0, 1000), 'id' => $id], - ); - } -} diff --git a/plugins/User/Infrastructure/Outbox/OutboxWriter.php b/plugins/User/Infrastructure/Outbox/OutboxWriter.php deleted file mode 100644 index 856d3ce..0000000 --- a/plugins/User/Infrastructure/Outbox/OutboxWriter.php +++ /dev/null @@ -1,93 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User\Infrastructure\Outbox; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Events\Contracts\IntegrationEventContract; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\User\Application\Ports\OutboxPort; - -/** - * Writes integration events into the central `user_outbox` table. - * - * MUST be called from inside the SAME transaction that mutates the user, so the - * event row and the state change commit (or roll back) atomically. A separate - * relay (`user:outbox:relay`) later dispatches pending rows to the EventBus — - * guaranteeing at-least-once delivery even across crashes. - * - * The injected DatabasePort is the CENTRAL connection (the `users` table is the - * global identity store and lives in the central DB). The Provider pins it via - * the ConnectionManager default so identity writes always target the central - * database. - */ -final class OutboxWriter implements OutboxPort -{ - public function __construct( - private readonly DatabasePort $db, - ) {} - - public function write(IntegrationEventContract $event): int - { - try { - $this->db->execute( - 'INSERT INTO user_outbox - (event_id, event_name, event_version, payload, - status, attempts, occurred_at, created_at) - VALUES - (:event_id, :event_name, :event_version, :payload, - 0, 0, :occurred_at, :created_at)', - [ - 'event_id' => self::uuid(), - 'event_name' => $event->name(), - 'event_version' => $event->version(), - 'payload' => json_encode($event->payload(), JSON_THROW_ON_ERROR), - 'occurred_at' => self::now(), - 'created_at' => self::now(), - ], - ); - - return (int) $this->db->lastInsertId(); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to enqueue outbox event.', - layer: 'repository.user.outbox', - context: ['event' => $event->name()], - previous: $e, - ); - } - } - - public function markDispatched(int $id): void - { - try { - $this->db->execute( - 'UPDATE user_outbox - SET status = 1, dispatched_at = :dispatched_at - WHERE id = :id', - ['dispatched_at' => self::now(), 'id' => $id], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to mark outbox event dispatched.', - layer: 'repository.user.outbox', - context: ['id' => $id], - previous: $e, - ); - } - } - - private static function now(): string - { - return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - } - - private static function uuid(): string - { - $b = random_bytes(16); - $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); - $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); - } -} diff --git a/plugins/User/Infrastructure/Persistence/FeedbackRepository.php b/plugins/User/Infrastructure/Persistence/FeedbackRepository.php deleted file mode 100644 index 3e39531..0000000 --- a/plugins/User/Infrastructure/Persistence/FeedbackRepository.php +++ /dev/null @@ -1,145 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User\Infrastructure\Persistence; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\User\API\DTOs\ListFeedbackQuery; -use Plugins\User\Application\Ports\FeedbackStore; -use Plugins\User\Domain\Entities\FeedbackEntry; - -/** - * FeedbackRepository — DatabasePort ONLY. - * - * The injected DatabasePort is the request's TENANT connection (rebound by - * Tenancy's TenantContextStage), so every row lands in the submitter's tenant - * database. There is therefore NO tenant_id column — the database IS the tenant - * boundary. The `user_feedback` table is owned by the plugin's tenant-template - * migration; this class never creates or alters schema. - * - * Invariants: - * - Every query is parameterised (no interpolation). - * - \PDOException never escapes — it is translated to RepositoryException. - * - Exception context carries IDs only — never the message body (no PII). - */ -final class FeedbackRepository implements FeedbackStore -{ - private const TABLE = 'user_feedback'; - - private const COLUMNS = 'feedback_id, user_id, category, rating, message, status, created_at'; - - public function __construct( - private readonly DatabasePort $db, - ) {} - - public function insert(FeedbackEntry $entry): void - { - try { - $this->db->execute( - 'INSERT INTO ' . self::TABLE . ' - (user_id, feedback_id, category, rating, message, status, created_at) - VALUES - (:user_id, :feedback_id, :category, :rating, :message, :status, :created_at)', - [ - 'user_id' => $entry->userId(), - 'feedback_id' => $entry->id()->value(), - 'category' => $entry->category()?->value, - 'rating' => $entry->rating()?->value(), - 'message' => $entry->message()->value(), - 'status' => $entry->status()->value, - 'created_at' => $entry->createdAt()->format('Y-m-d H:i:s'), - ], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to insert feedback.', - layer: 'repository.feedback', - context: ['feedbackId' => $entry->id()->value()], - previous: $e, - ); - } - } - - public function find(string $feedbackId): ?FeedbackEntry - { - try { - $row = $this->db->queryOne( - 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . ' - WHERE feedback_id = :id LIMIT 1', - ['id' => $feedbackId], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to load feedback.', - layer: 'repository.feedback', - previous: $e, - ); - } - - return $row === null ? null : self::hydrate($row); - } - - public function paginate(ListFeedbackQuery $query): array - { - $params = ['limit' => $query->limit + 1]; - $where = []; - - if ($query->status !== null) { - $where[] = 'status = :status'; - $params['status'] = $query->status->value; - } - - if ($query->after !== null) { - // Keyset on the internal id resolved from the opaque public cursor. - $where[] = 'id < (SELECT id FROM ' . self::TABLE . ' WHERE feedback_id = :after)'; - $params['after'] = $query->after; - } - - $clause = $where === [] ? '' : ' WHERE ' . implode(' AND ', $where); - - try { - $rows = $this->db->query( - 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . $clause . ' - ORDER BY id DESC - LIMIT :limit', - $params, - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to list feedback.', layer: 'repository.feedback', previous: $e); - } - - $hasMore = count($rows) > $query->limit; - if ($hasMore) { - array_pop($rows); - } - - return [array_map(static fn(array $r): FeedbackEntry => self::hydrate($r), $rows), $hasMore]; - } - - public function updateStatus(string $feedbackId, string $status): bool - { - try { - $affected = $this->db->execute( - 'UPDATE ' . self::TABLE . ' SET status = :status WHERE feedback_id = :id', - ['status' => $status, 'id' => $feedbackId], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to update feedback status.', - layer: 'repository.feedback', - context: ['feedbackId' => $feedbackId], - previous: $e, - ); - } - - return $affected > 0; - } - - /** @param array<string, mixed> $row */ - private static function hydrate(array $row): FeedbackEntry - { - return FeedbackEntry::reconstitute($row); - } -} diff --git a/plugins/User/Infrastructure/Persistence/OutboxRepository.php b/plugins/User/Infrastructure/Persistence/OutboxRepository.php deleted file mode 100644 index 64048f7..0000000 --- a/plugins/User/Infrastructure/Persistence/OutboxRepository.php +++ /dev/null @@ -1,121 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User\Infrastructure\Persistence; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Events\Contracts\IntegrationEventContract; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\User\Application\Ports\OutboxPort; - -/** - * OutboxRepository — the SOLE data-access seam for the central `user_outbox` - * table (write + relay read/update). Access rule: DatabasePort ONLY. - * - * Write half enqueues integration events inside the SAME transaction that mutates - * the user, so the event row and the state change commit (or roll back) - * atomically. Relay half (pending/markDispatched/markFailed) is consumed by - * {@see \Plugins\User\Application\Services\OutboxRelayService}, which owns the - * dispatch policy — this class never touches the EventBus. - * - * The injected DatabasePort is the CENTRAL connection (the `users`/`user_outbox` - * tables are the global identity store); the Provider pins it via the - * ConnectionManager default. - */ -final class OutboxRepository implements OutboxPort -{ - private const MAX_ATTEMPTS = 10; - - public function __construct( - private readonly DatabasePort $db, - ) {} - - // ── write side (in-transaction enqueue) ────────────────────────────────── - - public function write(IntegrationEventContract $event): int - { - try { - $this->db->execute( - 'INSERT INTO user_outbox - (event_id, event_name, event_version, payload, - status, attempts, occurred_at, created_at) - VALUES - (:event_id, :event_name, :event_version, :payload, - 0, 0, :occurred_at, :created_at)', - [ - 'event_id' => self::uuid(), - 'event_name' => $event->name(), - 'event_version' => $event->version(), - 'payload' => json_encode($event->payload(), JSON_THROW_ON_ERROR), - 'occurred_at' => self::now(), - 'created_at' => self::now(), - ], - ); - - return (int) $this->db->lastInsertId(); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to enqueue outbox event.', - layer: 'repository.user.outbox', - context: ['event' => $event->name()], - previous: $e, - ); - } - } - - // ── relay side (read + status transitions) ─────────────────────────────── - - /** @return list<array<string,mixed>> Pending rows, oldest first. */ - public function pending(int $limit): array - { - // LIMIT must be inlined as a validated integer: bound params are sent as - // strings (execute($params) → PDO::PARAM_STR), and native prepares - // (EMULATE_PREPARES=false) reject `LIMIT '100'` as a syntax error. - $limit = max(1, min(1000, $limit)); - - try { - return $this->db->query( - 'SELECT id, event_name, event_version, payload, attempts - FROM user_outbox - WHERE status = 0 - ORDER BY occurred_at ASC, id ASC - LIMIT ' . $limit, - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to read outbox.', layer: 'repository.user.outbox', previous: $e); - } - } - - public function markDispatched(int $id): void - { - $this->db->execute( - 'UPDATE user_outbox SET status = 1, dispatched_at = :now, attempts = attempts + 1 - WHERE id = :id AND status = 0', - ['now' => self::now(), 'id' => $id], - ); - } - - public function markFailed(int $id, int $attempts, string $error): void - { - $status = $attempts >= self::MAX_ATTEMPTS ? 2 : 0; // park as failed, else retry next run - $this->db->execute( - 'UPDATE user_outbox SET status = :status, attempts = :attempts, last_error = :err - WHERE id = :id', - ['status' => $status, 'attempts' => $attempts, 'err' => mb_substr($error, 0, 1000), 'id' => $id], - ); - } - - private static function now(): string - { - return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - } - - private static function uuid(): string - { - $b = random_bytes(16); - $b[6] = chr((ord($b[6]) & 0x0f) | 0x40); - $b[8] = chr((ord($b[8]) & 0x3f) | 0x80); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); - } -} diff --git a/plugins/User/Infrastructure/Persistence/UserRepository.php b/plugins/User/Infrastructure/Persistence/UserRepository.php deleted file mode 100644 index 0e63434..0000000 --- a/plugins/User/Infrastructure/Persistence/UserRepository.php +++ /dev/null @@ -1,385 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User\Infrastructure\Persistence; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\OptimisticLockException; -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\User\API\DTOs\ListUsersQuery; -use Plugins\User\Application\Ports\UserStore; -use Plugins\User\Domain\Entities\User; -use Plugins\User\Domain\Exceptions\DuplicateUserException; - -/** - * UserRepository — DatabasePort ONLY. The `users` table is owned by the plugin - * migration; this class never creates or alters schema. - * - * `users` is the GLOBAL central identity table: identity is centralized and - * username/email are globally unique. The injected DatabasePort is the CENTRAL - * connection (pinned by the Provider via the ConnectionManager default) so - * identity reads/writes always target the central database. - * - * Invariants: - * - Every query is parameterised (no interpolation). - * - Reads exclude soft-deleted rows (deleted_at IS NULL). - * - Writes are optimistic-locked on `version`; a stale write throws - * OptimisticLockException (→ HTTP 409) instead of silently clobbering. - * - Exception context carries IDs only — never raw email/username (no PII in - * logs). - */ -final class UserRepository implements UserStore -{ - private const TABLE = 'users'; - - private const COLUMNS = - 'user_id, username, email, password_hash, remember_token, - version, email_verified_at, email_verification_token_hash, - email_verification_expires_at, created_at'; - - public function __construct( - private readonly DatabasePort $db, - ) {} - - /** - * Keyset-paginated listing (stable, O(1) deep pages). Fetches one extra row - * to compute hasMore without a COUNT. - * - * @return array{0: list<User>, 1: bool} [users, hasMore] - */ - public function paginate(ListUsersQuery $query): array - { - // Inline LIMIT as a validated int: bound params bind as strings and - // native prepares (EMULATE_PREPARES=false) reject `LIMIT '100'`. - $limit = max(1, min(1001, $query->limit + 1)); - $params = []; - $cursor = ''; - if ($query->after !== null) { - // user_id DESC → fetch rows strictly "older" than the cursor. - $cursor = ' AND user_id < :after'; - $params['after'] = $query->after; - } - - try { - $rows = $this->db->query( - 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . ' - WHERE deleted_at IS NULL' . $cursor . ' - ORDER BY user_id DESC - LIMIT ' . $limit, - $params, - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to list users.', layer: 'repository.user', previous: $e); - } - - $hasMore = count($rows) > $query->limit; - if ($hasMore) { - array_pop($rows); - } - - return [array_map(static fn(array $r): User => self::hydrate($r), $rows), $hasMore]; - } - - public function find(string $userId): ?User - { - $row = $this->fetchBy('user_id', $userId); - return $row === null ? null : self::hydrate($row); - } - - /** Look up an active user by the SHA-256 hash of a "remember me" token. */ - public function findByRememberToken(string $tokenHash): ?User - { - // An empty hash must never match — guard so a NULL/blank column can't - // authenticate a forged empty cookie. - if ($tokenHash === '') { - return null; - } - - $row = $this->fetchBy('remember_token', $tokenHash); - - return $row === null ? null : self::hydrate($row); - } - - /** - * Resolve a user by the SHA-256 hash of a pending email-verification token. - * Expiry is checked in the service (it holds the clock); an empty hash never - * matches so a blank/NULL column cannot confirm a forged empty token. - */ - public function findByVerificationTokenHash(string $tokenHash): ?User - { - if ($tokenHash === '') { - return null; - } - - $row = $this->fetchBy('email_verification_token_hash', $tokenHash); - - return $row === null ? null : self::hydrate($row); - } - - /** Persist (or clear, with null) the remember-token hash for a user. */ - public function updateRememberToken(string $userId, ?string $tokenHash): void - { - try { - $this->db->execute( - 'UPDATE ' . self::TABLE . ' - SET remember_token = :token, updated_at = :now - WHERE user_id = :user_id AND deleted_at IS NULL', - ['token' => $tokenHash, 'now' => self::now(), 'user_id' => $userId], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to update remember token.', layer: 'repository.user', previous: $e); - } - } - - /** Look up by username OR email — used for credential verification/login. */ - public function findByIdentifier(string $identifier): ?User - { - try { - $row = $this->db->queryOne( - 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . ' - WHERE (username = :id OR email = :email) - AND deleted_at IS NULL - LIMIT 1', - ['id' => $identifier, 'email' => mb_strtolower($identifier)], - ); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to load user by identifier.', layer: 'repository.user', previous: $e); - } - - return $row === null ? null : self::hydrate($row); - } - - public function existsByUsernameOrEmail(string $username, string $email, ?string $exceptUserId = null): bool - { - $sql = 'SELECT 1 AS hit FROM ' . self::TABLE . ' - WHERE (username = :username OR email = :email) - AND deleted_at IS NULL'; - $params = ['username' => $username, 'email' => $email]; - - if ($exceptUserId !== null) { - $sql .= ' AND user_id <> :except'; - $params['except'] = $exceptUserId; - } - - try { - $row = $this->db->queryOne($sql . ' LIMIT 1', $params); - } catch (\Throwable $e) { - throw new RepositoryException('Failed to check user uniqueness.', layer: 'repository.user', previous: $e); - } - - return $row !== null; - } - - public function insert(User &$user): void - { - // The entity is the source of truth for created_at (set at register()). - // updated_at equals it on first insert. - $createdAt = $user->createdAt(); - $updatedAt = $createdAt; - - try { - $this->db->execute( - 'INSERT INTO ' . self::TABLE . ' - (user_id, username, email, password_hash, remember_token, - version, email_verified_at, email_verification_token_hash, - email_verification_expires_at, created_at, updated_at) - VALUES - (:user_id, :username, :email, :password_hash, :remember_token, - :version, :email_verified_at, :verif_token, :verif_expires, - :created_at, :updated_at)', - [ - 'user_id' => $user->id(), - 'username' => $user->username(), - 'email' => $user->email(), - 'password_hash' => $user->passwordHash(), - 'remember_token' => $user->rememberToken(), - 'version' => $user->version(), - 'email_verified_at' => self::fmt($user->emailVerifiedAt()), - 'verif_token' => $user->emailVerificationTokenHash(), - 'verif_expires' => self::fmt($user->emailVerificationExpiresAt()), - 'created_at' => self::fmt($createdAt), - 'updated_at' => self::fmt($updatedAt), - ], - ); - } catch (\Throwable $e) { - if (self::isUniqueViolation($e)) { - throw new DuplicateUserException(); - } - throw new RepositoryException( - 'Failed to insert user.', - layer: 'repository.user', - context: ['userId' => $user->id()], - previous: $e, - ); - } - - // Reflect the persisted timestamps back onto the caller's entity, then - // mark it clean so it reports no pending changes after the write. - $user->setAttribute('updated_at', $updatedAt); - $user->syncOriginal(); - } - - /** - * Optimistic-locked update. The entity has already bumped its version (via - * commitChanges); we write WHERE version = newVersion - 1 and require one - * affected row. - */ - public function update(User $user): void - { - $expected = $user->version() - 1; - - try { - $affected = $this->db->execute( - 'UPDATE ' . self::TABLE . ' SET - username = :username, - email = :email, - password_hash = :password_hash, - remember_token = :remember_token, - email_verified_at = :email_verified_at, - email_verification_token_hash = :verif_token, - email_verification_expires_at = :verif_expires, - version = :version, - updated_at = :updated_at - WHERE user_id = :user_id - AND version = :expected AND deleted_at IS NULL', - [ - 'username' => $user->username(), - 'email' => $user->email(), - 'password_hash' => $user->passwordHash(), - 'remember_token' => $user->rememberToken(), - 'email_verified_at' => self::fmt($user->emailVerifiedAt()), - 'verif_token' => $user->emailVerificationTokenHash(), - 'verif_expires' => self::fmt($user->emailVerificationExpiresAt()), - 'version' => $user->version(), - 'updated_at' => self::now(), - 'user_id' => $user->id(), - 'expected' => $expected, - ], - ); - } catch (\Throwable $e) { - if (self::isUniqueViolation($e)) { - throw new DuplicateUserException(); - } - throw new RepositoryException( - 'Failed to update user.', - layer: 'repository.user', - context: ['userId' => $user->id()], - previous: $e, - ); - } - - - if ($affected < 1) { - throw new OptimisticLockException( - 'User was modified concurrently; reload and retry.', - layer: 'repository.user', - context: ['userId' => $user->id(), 'expectedVersion' => $expected], - ); - } - } - - /** - * Persist only a re-hashed password (rehash-on-login). No version bump, no - * events — it is a transparent credential upgrade, not a domain change. - */ - public function persistRehash(string $userId, string $passwordHash): void - { - try { - $this->db->execute( - 'UPDATE ' . self::TABLE . ' SET password_hash = :hash - WHERE user_id = :user_id AND deleted_at IS NULL', - ['hash' => $passwordHash, 'user_id' => $userId], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to upgrade password hash.', - layer: 'repository.user', - context: ['userId' => $userId], - previous: $e, - ); - } - } - - /** Soft delete — sets deleted_at, never removes the row. */ - public function delete(string $userId): bool - { - $now = self::now(); - - try { - $affected = $this->db->execute( - 'UPDATE ' . self::TABLE . ' - SET deleted_at = :now, updated_at = :now - WHERE user_id = :user_id AND deleted_at IS NULL', - ['now' => $now, 'user_id' => $userId], - ); - } catch (\Throwable $e) { - throw new RepositoryException( - 'Failed to delete user.', - layer: 'repository.user', - context: ['userId' => $userId], - previous: $e, - ); - } - - return $affected > 0; - } - - /** @return array<string, mixed>|null */ - private function fetchBy(string $column, string $value): ?array - { - try { - return $this->db->queryOne( - 'SELECT ' . self::COLUMNS . ' FROM ' . self::TABLE . ' - WHERE ' . $column . ' = :value AND deleted_at IS NULL - LIMIT 1', - ['value' => $value], - ); - } catch (\Throwable $e) { - // NB: no PII (the looked-up value) in the log context — only the column. - throw new RepositoryException( - 'Failed to load user.', - layer: 'repository.user', - context: ['by' => $column], - previous: $e, - ); - } - } - - /** @param array<string, mixed> $row */ - private static function hydrate(array $row): User - { - // The Entity base hydrates from the raw row keyed by column name and - // records no events; casts apply lazily on read. - return User::reconstitute($row); - } - - private static function now(): string - { - return (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - } - - private static function fmt(?\DateTimeImmutable $dt): ?string - { - return $dt?->format('Y-m-d H:i:s'); - } - - /** Detect a UNIQUE/duplicate-key violation across PDO drivers. */ - private static function isUniqueViolation(\Throwable $e): bool - { - for ($cur = $e; $cur !== null; $cur = $cur->getPrevious()) { - if ($cur instanceof \PDOException) { - // SQLSTATE 23000/23505 = integrity constraint / unique violation. - $sqlState = is_array($cur->errorInfo ?? null) ? ($cur->errorInfo[0] ?? '') : (string) $cur->getCode(); - if (in_array($sqlState, ['23000', '23505'], true)) { - return true; - } - } - $msg = strtolower($cur->getMessage()); - if (str_contains($msg, 'duplicate') || str_contains($msg, 'unique constraint')) { - return true; - } - } - return false; - } -} diff --git a/plugins/User/Infrastructure/Persistence/UserSettingsRepository.php b/plugins/User/Infrastructure/Persistence/UserSettingsRepository.php deleted file mode 100644 index 19c0564..0000000 --- a/plugins/User/Infrastructure/Persistence/UserSettingsRepository.php +++ /dev/null @@ -1,174 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User\Infrastructure\Persistence; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Exceptions\RepositoryException; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use Plugins\User\Domain\Entities\UserNotificationPreferences; -use Plugins\User\Domain\Entities\UserPreferences; -use Plugins\User\Domain\Entities\UserPrivacySettings; -use Plugins\User\Domain\Entities\UserProfile; - -/** - * UserSettingsRepository — DatabasePort ONLY. One repository for the four - * per-user, TENANT-scoped settings singletons (profile, preferences, privacy, - * notifications). The injected port is the request's tenant connection, so every - * row lands in the user's tenant database. All writes use the portable upsert on - * the user_id key. - * - * Invariants: parameterised queries only; \PDOException is translated to - * RepositoryException; exception context carries the user id only (no PII). - */ -final class UserSettingsRepository -{ - public function __construct( - private readonly DatabasePort $db, - ) {} - - // ── profile ─────────────────────────────────────────────────────────────── - - public function findProfile(string $userId): ?UserProfile - { - $row = $this->one( - 'user_profiles', - 'user_id, first_name, last_name, avatar_url, timezone, locale, phone', - $userId, - 'profile', - ); - if ($row === null) { - return null; - } - - return UserProfile::reconstitute($row); - } - - public function saveProfile(UserProfile $p): void - { - $this->save('user_profiles', [ - 'user_id' => $p->userId(), - 'first_name' => $p->firstName(), - 'last_name' => $p->lastName(), - 'avatar_url' => $p->avatarUrl(), - 'timezone' => $p->timezone(), - 'locale' => $p->locale(), - 'phone' => $p->phone(), - ], $p->userId(), 'profile'); - } - - // ── preferences ───────────────────────────────────────────────────────────── - - public function findPreferences(string $userId): ?UserPreferences - { - $row = $this->one( - 'user_preferences', - 'user_id, language, currency, theme, reduce_motion, larger_text, high_contrast, screen_reader_hints', - $userId, - 'preferences', - ); - if ($row === null) { - return null; - } - - return UserPreferences::reconstitute($row); - } - - public function savePreferences(UserPreferences $p): void - { - $this->save('user_preferences', [ - 'user_id' => $p->userId(), - 'language' => $p->language(), - 'currency' => $p->currency(), - 'theme' => $p->theme()->value, - 'reduce_motion' => (int) $p->reduceMotion(), - 'larger_text' => (int) $p->largerText(), - 'high_contrast' => (int) $p->highContrast(), - 'screen_reader_hints' => (int) $p->screenReaderHints(), - ], $p->userId(), 'preferences'); - } - - // ── privacy ───────────────────────────────────────────────────────────────── - - public function findPrivacy(string $userId): ?UserPrivacySettings - { - $row = $this->one( - 'user_privacy_settings', - 'user_id, profile_visibility, show_phone, show_email, marketing_opt_in, analytics_opt_in', - $userId, - 'privacy', - ); - if ($row === null) { - return null; - } - - return UserPrivacySettings::reconstitute($row); - } - - public function savePrivacy(UserPrivacySettings $s): void - { - $this->save('user_privacy_settings', [ - 'user_id' => $s->userId(), - 'profile_visibility' => $s->profileVisibility()->value, - 'show_phone' => (int) $s->showPhone(), - 'show_email' => (int) $s->showEmail(), - 'marketing_opt_in' => (int) $s->marketingOptIn(), - 'analytics_opt_in' => (int) $s->analyticsOptIn(), - ], $s->userId(), 'privacy'); - } - - // ── notification preferences ────────────────────────────────────────────────── - - public function findNotifications(string $userId): ?UserNotificationPreferences - { - $columns = implode(', ', array_keys(UserNotificationPreferences::FLAG_DEFAULTS)); - $row = $this->one('user_notification_preferences', 'user_id, ' . $columns, $userId, 'notification_preferences'); - if ($row === null) { - return null; - } - - // $row carries user_id + every flag column; the entity casts them. - return UserNotificationPreferences::reconstitute($row); - } - - public function saveNotifications(UserNotificationPreferences $p): void - { - $values = ['user_id' => $p->userId()]; - foreach ($p->flags() as $key => $on) { - $values[$key] = (int) $on; - } - $this->save('user_notification_preferences', $values, $p->userId(), 'notification_preferences'); - } - - // ── shared helpers ─────────────────────────────────────────────────────────── - - /** @return array<string,mixed>|null */ - private function one(string $table, string $columns, string $userId, string $label): ?array - { - try { - return $this->db->queryOne( - 'SELECT ' . $columns . ' FROM ' . $table . ' WHERE user_id = :id LIMIT 1', - ['id' => $userId], - ); - } catch (\Throwable $e) { - throw new RepositoryException("Failed to load {$label}.", layer: 'repository.user_settings', previous: $e); - } - } - - /** @param array<string,mixed> $values */ - private function save(string $table, array $values, string $userId, string $label): void - { - $values['updated_at'] = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); - - try { - $this->db->upsert($table, $values, ['user_id']); - } catch (\Throwable $e) { - throw new RepositoryException( - "Failed to save {$label}.", - layer: 'repository.user_settings', - context: ['userId' => $userId], - previous: $e, - ); - } - } -} diff --git a/plugins/User/Provider.php b/plugins/User/Provider.php deleted file mode 100644 index 87b29e2..0000000 --- a/plugins/User/Provider.php +++ /dev/null @@ -1,228 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Plugins\User; - -use AlfacodeTeam\PhpServicePlatform\Kernel\Contracts\ModuleContract; -use AlfacodeTeam\PhpServicePlatform\Kernel\Container\ModuleContainer; -use AlfacodeTeam\PhpServicePlatform\Kernel\Database\TransactionManager; -use AlfacodeTeam\PhpServicePlatform\Kernel\Events\{DomainEventCollector, EventBus}; -use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Cli\CliPipeline; -use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; -use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\WorkerPipeline; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HashingPort; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HttpClientPort; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\MailPort; -use Plugins\Tenancy\API\Contracts\MembershipServiceContract; -use Plugins\User\Application\Ports\BreachChecker; -use Plugins\User\Infrastructure\Gateways\NullBreachChecker; -use Plugins\User\Infrastructure\Gateways\PwnedPasswordGateway; -use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Identity; -use Plugins\Audit\API\Contracts\AuditServiceContract; -use Plugins\Database\API\Contracts\DatabaseConnectionManagerContract; -use Plugins\User\API\Contracts\TenantProfileReaderContract; -use Plugins\User\API\Contracts\UserServiceContract; -use Plugins\User\Application\Services\TenantProfileProvisioner; -use Plugins\User\Application\Services\OutboxRelayService; -use Plugins\User\Application\Services\UserService; -use Plugins\User\Application\Services\UserSettingsService; -use Plugins\User\Infrastructure\Cli\RelayUserOutboxCommand; -use Plugins\User\Infrastructure\Http\Controllers\UserController; -use Plugins\User\Infrastructure\Http\Controllers\UserPageController; -use Plugins\User\Infrastructure\Http\Controllers\UserSettingsController; -use Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener; -use Plugins\User\Application\Ports\OutboxPort; -use Plugins\User\Infrastructure\Persistence\OutboxRepository; -use Plugins\User\Infrastructure\Persistence\UserRepository; -use Plugins\User\Infrastructure\Persistence\UserSettingsRepository; -use Plugins\View\API\Contracts\ViewRendererContract; - -/** - * User plugin — owns the 'user.management' domain. - * - * Requires: database.management (the `users` table is the GLOBAL central - * identity store — repository + outbox are pinned to the ConnectionManager - * default/central connection so identity I/O always targets the central DB), - * crypto.services (HashingPort), cache.redis - * (CachePort, login lockout), view.rendering (HTML UI). - * Publishes UserServiceContract for other modules (e.g. Auth). - * - * Reliable cross-module delivery uses a transactional outbox drained by the - * `user:outbox:relay` CLI command (registered in boot()). - */ -final class Provider implements ModuleContract -{ - public function solves(): string - { - return 'user.management'; - } - - /** @return list<class-string> */ - public function requires(): array - { - return [ - 'database.management', - 'crypto.services', - 'cache.redis', - 'view.rendering', - 'http.client', // breached-password screening (opt-in via USER_BREACH_CHECK) - 'validation.rules', - 'mail.delivery', - 'feedback.management', - 'audit.trail', - ]; - } - - /** @return list<class-string> */ - public function exposes(): array - { - // UserServiceContract is consumed cross-module (Auth/Tenancy); - // TenantProfileReaderContract lets Tenancy read the tenant user_profiles - // display data (full name) at selection without raw SQL. Settings stay - // internal to this plugin (their own controller) and are NOT published. - return [ - UserServiceContract::class, - TenantProfileReaderContract::class, - ]; - } - - public function register(ModuleContainer $container): void - { - // `users`/`user_outbox` live in the CENTRAL database. Pin both to the - // ConnectionManager default so identity I/O always targets the central - // connection regardless of any per-request DatabasePort rebinding. - $container->bindInternal(UserRepository::class, static fn(ModuleContainer $c) => - new UserRepository(self::central($c))); - - // Sole data-access seam for user_outbox (write + relay ops); central conn. - $container->bindInternal(OutboxRepository::class, static fn(ModuleContainer $c) => - new OutboxRepository(self::central($c))); - $container->bind(OutboxPort::class, static fn(ModuleContainer $c) => - $c->make(OutboxRepository::class)); - - // Breached-password screening (NIST 800-63B). Enabled with - // USER_BREACH_CHECK; uses the HIBP k-anonymity range API via - // HttpClientPort. Falls back to a no-op when disabled or no HTTP client - // is available, so the check is purely opt-in and never a hard dependency. - $container->bindInternal(BreachChecker::class, static function (ModuleContainer $c): BreachChecker { - $enabled = filter_var(env('USER_BREACH_CHECK', false), FILTER_VALIDATE_BOOL); - if (!$enabled || !$c->has(HttpClientPort::class)) { - return new NullBreachChecker(); - } - - return new PwnedPasswordGateway( - $c->make(HttpClientPort::class), - (int) (env('USER_BREACH_THRESHOLD') ?: 1), - ); - }); - - // Published tenant-profile read surface (Identity/UserDTO fullName). - // Resolver mode: the tenant connection is resolved per call from the - // tenantId, through Tenancy's published contract (optional — reads - // degrade to '' when Tenancy is absent). - // makeInScope: User cannot declare tenancy.routing in requires[] - // (Tenancy already requires user.management — a requires cycle would - // fail the boot), so a plain make() from the user.management scope - // throws. Resolve the PUBLIC contract under Tenancy's own scope, - // guarded — reads are best-effort by contract, so a request that never - // loaded Tenancy simply yields no profile data. - $container->bind(TenantProfileReaderContract::class, static function (ModuleContainer $c) { - $resolver = null; - try { - $resolver = $c->makeInScope(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class, 'tenancy.routing'); - } catch (\Throwable) { - // Tenancy absent for this request — profile reads degrade to ''. - } - - return new TenantProfileProvisioner(connections: $resolver); - }); - - $container->bind(UserServiceContract::class, static fn(ModuleContainer $c) => - new UserService( - repository: $c->make(UserRepository::class), - transaction: $c->make(TransactionManager::class), - collector: $c->make(DomainEventCollector::class), - outbox: $c->make(OutboxPort::class), - eventBus: $c->make(EventBus::class), - hasher: $c->make(HashingPort::class), - identity: $c->make(Identity::class), - cache: $c->make(CachePort::class), - audit: $c->make(AuditServiceContract::class), - breachChecker: $c->make(BreachChecker::class), - tenantId: $c->has('tenant.current') ? (string) $c->make('tenant.current') : null, - membership: $c->has(MembershipServiceContract::class) ? $c->make(MembershipServiceContract::class) : null, - profiles: $c->make(TenantProfileReaderContract::class), - )); - - // Public/admin JSON controller. Bound explicitly so the OPTIONAL MailPort - // is injected only when a project wired one (else null → email is skipped). - $container->bindInternal(UserController::class, static fn(ModuleContainer $c) => - new UserController( - $c->make(UserServiceContract::class), - $c->has(MailPort::class) ? $c->make(MailPort::class) : null, - )); - - // HTML page controller (renders the AJAX-driven UI shell). - $container->bindInternal(UserPageController::class, static fn(ModuleContainer $c) => - new UserPageController( - $c->make(ViewRendererContract::class), - )); - - // ── Per-user settings (TENANT-scoped singletons, internal) ─────────── - // One service + one repository for all four settings resources. Scoped - // to the authenticated Identity (self only); tenant-routed DatabasePort. - $container->bindInternal(UserSettingsRepository::class, static fn(ModuleContainer $c) => - new UserSettingsRepository($c->make(DatabasePort::class))); - - $container->bindInternal(UserSettingsService::class, static fn(ModuleContainer $c) => - new UserSettingsService( - $c->make(UserSettingsRepository::class), - $c->make(Identity::class), - $c->make(AuditServiceContract::class), - )); - - $container->bindInternal(UserSettingsController::class, static fn(ModuleContainer $c) => - new UserSettingsController($c->make(UserSettingsService::class))); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Outbox relay — must read the CENTRAL `user_outbox` (the ConnectionManager - // default), NOT the kernel DatabasePort port (which a project may bind to - // an unrelated/unconfigured connection). Build it on the CLI path with a - // scoped container that carries the Database ConnectionManager so - // OutboxRepository targets the same central DB the write side uses. - // Deferred so HTTP/worker builds never pay for it. - $cli->defer(function (CliPipeline $cli) use ($events): void { - $c = new ModuleContainer($cli->container()); - $c->setScope('database.management'); - (new \Plugins\Database\Provider())->register($c); - $c->setScope('user.management'); - (new self())->register($c); - - $cli->command(new RelayUserOutboxCommand( - new OutboxRelayService( - $c->makeInScope(OutboxRepository::class, 'user.management'), - $events, - ), - )); - }); - - // Write the per-tenant user_profiles row from an at-signup profile block. - // Resolved from the CoreContainer: the PROJECT binds this WITH a - // TenantConnectionResolverContract to make it write (else it no-ops). - $events->subscribe('user.registered', ProvisionTenantProfileListener::class); - } - - /** - * The CENTRAL connection (always-connected DB that owns the global `users` - * table) via the ConnectionManager default. - */ - private static function central(ModuleContainer $c): DatabasePort - { - return $c->make(DatabaseConnectionManagerContract::class)->default(); - } -} diff --git a/plugins/User/README.md b/plugins/User/README.md deleted file mode 100644 index 66384ef..0000000 --- a/plugins/User/README.md +++ /dev/null @@ -1,552 +0,0 @@ -# User Plugin - -> Solves: **`user.management`** · Namespace: **`Plugins\User\`** · Type: on-demand GDA module - -The User plugin owns the **user.management** business domain on the AlfacodeTeam -PhpServicePlatform (GDA) framework. It provides enterprise-grade user -registration, lookup, partial update, email verification, soft-deletion, and -**timing-safe, rate-limited credential verification** — all over the GLOBAL -central `users` identity table. - -It is the canonical reference for how a first-party plugin is structured: pure -Domain, an Application service that owns the transaction + events, Infrastructure -adapters behind ports, and a published API contract that other modules consume. - ---- - -## Table of contents - -1. [What it does](#what-it-does) -2. [Architecture at a glance](#architecture-at-a-glance) -3. [Directory layout](#directory-layout) -4. [Data model](#data-model) -5. [HTTP API](#http-api) -6. [Web UI (AJAX + CSRF)](#web-ui-ajax--csrf) -7. [Tenant-scoped sub-resources (feedback & settings)](#tenant-scoped-sub-resources-feedback--settings) -8. [Security model](#security-model) -9. [Reliability: the transactional outbox](#reliability-the-transactional-outbox) -10. [Installation & wiring](#installation--wiring) -11. [Configuration](#configuration) -12. [Using it from another plugin](#using-it-from-another-plugin) -13. [Using it from a project](#using-it-from-a-project) -14. [CLI](#cli) -15. [Testing](#testing) -16. [Extending the pattern](#extending-the-pattern) - ---- - -## What it does - -| Capability | Entry point | Notes | -|---|---|---| -| Register (public) | `POST /ajx/users` | Public self-signup, rate-limited. Returns `202 {status:"pending_verification"}` — **no identity data**. Queues a verification email (optional `MailPort`). May submit a profile block → tenant `user_profiles`. Emits `user.registered` | -| Register (admin) | `POST /ajx/admin/users` | `auth` + `user:create`. Returns the FULL created record for the admin table | -| Verify email (public) | `POST /ajx/users/verify` | **Unauthenticated**, token-based: SHA-256-stored, one-time, 24h expiry. Sets `email_verified_at` | -| Verify email (self/admin) | `POST /ajx/users/{id}/verify-email` | Authenticated variant (self or `user:update-any`) | -| List users | `GET /ajx/users` | Admin-only; keyset paginated | -| Show a user | `GET /ajx/users/{id}` | Self or `user:read-any` | -| Update (partial) | `PUT/PATCH /ajx/users/{id}` | Self or `user:update-any`; optimistic-locked; emits `user.updated` | -| Soft-delete | `DELETE /ajx/users/{id}` | Self or `user:delete-any`; emits `user.deleted` | -| Verify credentials | `UserServiceContract::verifyCredentials()` | Timing-safe, lockout, rehash-on-login; requires a verified email | -| **Settings** | `GET/PUT /ajx/{profile,preferences,privacy,notification-preferences}` | TENANT-scoped, self-only; one consolidated service | -| HTML UI | `GET /users[...]`, `/account/settings` | AJAX-driven, cookie auth, CSRF on every form | - -> **Recent changes** -> - **Feedback moved out** into its own [`Plugins\Feedback`](../Feedback/README.md) plugin (one plugin, one domain). The `/ajx/feedback` routes + `user_feedback` table now live there. -> - **Registration split** into public (`registerPublic` → token only) vs admin (`register` → full record); public **email verification is token-based** (hashed, one-time, 24h) via `verifyEmailByToken`. -> - **Input DTOs** now extend `Plugins\Validation\AbstractDto` and declare `rules()` instead of hand-rolled validation. - ---- - -## Architecture at a glance - -``` -HTTP ─▶ UserController (thin) CLI ─▶ user:outbox:relay - │ DTO → service → Response │ - ▼ ▼ - UserServiceContract ◀── published to other modules - │ - UserService (Application) - ├─ authorization (Identity) ── self-or-permission - ├─ TransactionManager begin/commit/rollback - ├─ DomainEventCollector (in-tx buffer) - ├─ OutboxPort ─▶ user_outbox (same tx) ── at-least-once events - ├─ HashingPort (crypto.services) ── bcrypt, rehash-on-login - ├─ CachePort ─▶ login lockout - └─ AuditLogger - │ - UserStore (port) ◀── UserRepository (central DatabasePort, global, version-locked) - │ - User aggregate (Domain — zero external imports) - ├─ UserId (monotonic ULID), Username, Email, PasswordPolicy - └─ records UserRegistered/Updated/Deleted domain events - (login gate = email_verified_at; no status column) -``` - -**The five GDA access rules hold:** Controller → Service (contract only), -Service → Repository + Gateway, Repository → DatabasePort only, Domain imports -nothing external. - ---- - -## Directory layout - -``` -plugins/User/ -├── module.json single source of truth (routes, requires, config) -├── Provider.php DI wiring + CLI registration -├── API/ -│ ├── Contracts/UserServiceContract.php the ONLY published interface -│ ├── DTOs/ Register/Update/VerifyEmail/User/ListUsersQuery/UserPage -│ │ + Submit/ListFeedbackQuery/FeedbackPage -│ │ + Update{Profile,Preferences,Privacy,NotificationPreferences} -│ └── IntegrationEvents/ UserRegistered/Updated/Deleted, FeedbackSubmitted, Generic -├── Application/ -│ ├── Ports/ UserStore, OutboxPort, FeedbackStore internal DIP seams (testability) -│ └── Services/ UserService, FeedbackService, UserSettingsService -├── Domain/ -│ ├── Entities/ User, FeedbackEntry, UserProfile, UserPreferences, -│ │ UserPrivacySettings, UserNotificationPreferences -│ ├── Events/ UserRegistered/Updated/Deleted domain events -│ ├── Exceptions/DuplicateUserException.php -│ └── ValueObjects/ UserId, Ulid, Username, Email, PasswordPolicy, -│ Feedback{Id,Category,Rating,Status,Message}, Theme, ProfileVisibility -├── Infrastructure/ -│ ├── Audit/AuditLogger.php -│ ├── Cli/RelayUserOutboxCommand.php user:outbox:relay -│ ├── Http/Controllers/ UserController, UserPageController, -│ │ FeedbackController, UserSettingsController -│ ├── Outbox/ OutboxWriter, OutboxRelay -│ └── Persistence/ UserRepository (central), FeedbackRepository + UserSettingsRepository (tenant) -├── config/user.php -├── database/ -│ ├── migrations/ create_user_table, create_user_outbox_table (CENTRAL) -│ ├── tenant-template/ user_profiles, user_privacy_settings, user_preferences, -│ │ user_notification_preferences, user_feedback (per-TENANT) -│ ├── seeders/UserSeeder.php -│ └── factories/UserFactory.php -└── resources/views/ layouts/app.php, users/{index,create,edit,show}.php, - account/{settings,feedback}.php -``` - ---- - -## Data model - -`users` is the **GLOBAL central identity table** (authentication is centralized, -username/email globally unique). Owned by the migration; the repository never -alters schema. - -| Column | Type | Purpose | -|---|---|---| -| `id` | bigint PK | internal surrogate (never leaves persistence) | -| `user_id` | char(31) | **public** ULID identifier | -| `username` | varchar(50) | **globally** unique | -| `email` | varchar(150) | **globally** unique, lowercased | -| `password_hash` | char(60) | bcrypt (exactly 60 chars) | -| `remember_token` | char(64) null | SHA-256 of the remember-me token | -| `version` | int unsigned | optimistic-lock version | -| `email_verified_at` | timestamp null | set on confirmation — **this is the login gate** | -| `created_at`/`updated_at`/`deleted_at` | timestamps | soft-delete aware | - -Uniqueness is **global** (`uniq_username`, `uniq_email`). - -> **Login gate = a verified email.** There is no `status` column. A user can -> authenticate only once `email_verified_at` is set (`UserService::verifyCredentials` -> checks `User::canLogin()`); "disable an account" is done via soft delete. The -> earlier `status` / `auth_provider` / `provider_subject` / `is_platform_admin` / -> `last_login_at` columns were removed to keep the table lean — federation and -> platform-admin, if needed, belong in their own tables/claims. - -The repository and the `user_outbox` writer are **pinned to the central -connection** (the `ConnectionManager` default) so identity I/O always targets the -central database regardless of any per-request `DatabasePort` rebinding. A second -table, `user_outbox`, stores integration events for reliable delivery. - ---- - -## HTTP API - -All API responses use the framework envelope: - -```jsonc -// success -{ "data": { "id": "01J…", "username": "jane", "email": "jane@example.com", - "emailVerified": true, "createdAt": "2026-…" } } - -// list (keyset paginated) -{ "data": [ … ], "meta": { "count": 25, "limit": 25, "has_more": true, - "next_cursor": "01J…" } } - -// error -{ "error": { "code": "…", "message": "…", "fields": { "email": "…" } } } -``` - -### Register - -```bash -curl -X POST https://app.example.com/ajx/users \ - -H 'Content-Type: application/json' \ - -d '{"username":"jane","email":"jane@example.com","password":"C0rrectHorse!"}' -# 201 → { "data": { … } } emits user.registered -``` - -### List (paginate) - -```bash -curl 'https://app.example.com/ajx/users?limit=50&after=01J…' \ - -H 'Authorization: Bearer <token>' # or same-site session cookie -``` - -### Update (partial / PATCH semantics) - -```bash -curl -X PUT https://app.example.com/ajx/users/01J… \ - -H 'Content-Type: application/json' -H 'X-CSRF-Token: …' \ - -d '{"email":"new@example.com"}' # only changed fields; bumps version -``` - -A concurrent edit that loses the version race → **HTTP 409** (OptimisticLock). -A duplicate username/email → **HTTP 409/422** (DuplicateUserException). - ---- - -## Web UI (AJAX + CSRF) - -`UserPageController` renders four pages (`/users`, `/users/create`, -`/users/{id}`, `/users/{id}/edit`). Each is a thin HTML shell that hydrates over -AJAX against `/ajx/users`. Authentication is **same-site cookie** (no bearer -token in the browser). - -**CSRF on every form:** the page controller (via `ViewController` → -`InteractsWithCsrf`) mints an HMAC token bound to a dedicated `csrf_bind` -cookie. Each page exposes it as `<meta name="csrf-token">` and a hidden -`_csrf_token` field; the shared `window.UserApp` client sends it as the -`X-CSRF-Token` header on every unsafe (POST/PUT/PATCH/DELETE) request. - -> **Project requirement:** wire a `CsrfTokenLayer` in `withSecurity()` with -> `bindCookie: 'csrf_bind'`, the same `lifetime` as `CSRF_LIFETIME`, and **do not -> exempt `/api`** (the UI authenticates by cookie, so the write endpoints must be -> CSRF-checked). Otherwise tokens are sent but never validated. - ---- - -## Tenant-scoped sub-resources (feedback & settings) - -Beyond central identity, the plugin owns per-user data that lives in the -**tenant** database (not central): **feedback** and the four **settings** -singletons (profile, preferences, privacy, notification preferences). - -Key differences from the identity tables: - -- **Tenant-routed, not central.** Their repositories take the request's - `DatabasePort` **after** `TenantContextStage` rebinds it — so rows land in the - caller's tenant DB. Schema ships in `database/tenant-template/` and is applied - per-tenant by the Tenancy tooling, **not** `migrate:run`. -- **`user_id` is the ULID** (`char(31)`, the central `users.user_id`) — a soft - reference, no cross-DB foreign key. -- **Guarded by `auth` + `tenant` filters.** Every route declares - `"filters": ["auth", "tenant"]`; the `tenant` filter (from the Tenancy plugin) - returns **409** when no tenant is active, so these never hit central by mistake. -- **Self-scoped.** The user id always comes from `Identity`, never the body. - -### Feedback — full CRUD - -| Verb | Path | Who | Service | -| --- | --- | --- | --- | -| Create | `POST /ajx/feedback` | any authenticated user (`throttle:5,1`) | `FeedbackService::submit` | -| List | `GET /ajx/feedback` | `feedback:manage` (admin triage) | `list` | -| Read one | `GET /ajx/feedback/{id}` | self or `feedback:manage` | `find` | -| Update status | `PATCH /ajx/feedback/{id}` | `feedback:manage` (forward-only) | `updateStatus` | - -Emits `feedback.submitted` (dispatched **directly** after the write — not via the -outbox, since the write is a single tenant-scoped insert). - -### Settings — read/update, one service - -`UserSettingsService` + `UserSettingsRepository` back all four resources -(`getX`/`updateX`); each is `GET/PUT /ajx/{profile,preferences,privacy,notification-preferences}`, -self-scoped, idempotent `PUT` via the portable `upsert`, audited on write. Demo -UI at `/account/settings` and `/account/feedback`. - -> **Internal, not published.** Feedback + settings are consumed only by this -> plugin's own controllers, so their services are bound `bindInternal` and are -> **not** in `exposes()` — only `UserServiceContract` is cross-module. Services -> return the domain **entity**; the controller serialises via `entity->toArray()` -> (no separate output DTO). - ---- - -## Security model - -| Concern | Mechanism | -|---|---| -| Authorization | In the **service**: admins act on anyone (`user:list`, `user:*-any`); a non-admin only on their own record (`hash_equals` self-check) | -| Password storage | `HashingPort` (bcrypt); never `password_hash()` directly; plaintext never persisted/logged/returned | -| Password strength | `PasswordPolicy` VO — length 12–72, ≥3 char classes, deny-list | -| Login timing | Constant-time decoy hash for unknown users | -| Brute force | Per-identifier lockout via `CachePort` (5 failures / 15 min) | -| Hash upgrades | `needsRehash()` → transparent rehash on successful login | -| Central identity | Identity is GLOBAL (central `users`); repository pinned to the central connection | -| PII in logs | Exception context carries IDs only; audit pseudonymises identifiers | -| Credentials in transit | `password_hash` / `remember_token` never appear in any DTO/JSON | -| Audit | `AuditLogger` writes structured JSON for register/update/delete/login/lockout/rehash | - ---- - -## Reliability: the transactional outbox - -Integration events are **not** dispatched directly after commit (which can be -lost on a crash). Instead: - -1. The service writes the event into `user_outbox` **inside the same - transaction** as the state change (atomic). -2. `user:outbox:relay` (cron/supervised) reads pending rows and dispatches them - to the `EventBus` — **at-least-once**, idempotency keyed by the event UUID. - -``` -register/update/delete ──tx──▶ [users row + user_outbox row] COMMIT - │ - cron: php cli user:outbox:relay ────▶ EventBus ──▶ subscribers -``` - -Consumers must dedupe on the event id (it may be redelivered after a crash -between dispatch and mark-dispatched). - ---- - -## Installation & wiring - -1. **Enable the plugin** (publishes config/, database/, resources/): - - ```bash - hkm plugins enable User - ``` - -2. **Register the Provider** in your project bootstrap: - - ```php - // projects/<name>/bootstrap/app.php - return $builder - ->withModules([ - Plugins\Crypto\Provider::class, // crypto.services (HashingPort) - Plugins\View\Provider::class, // view.rendering - Plugins\User\Provider::class, // user.management - ]) - ->build(); - ``` - -3. **Ensure required capabilities are available:** `database.management` - (DatabaseConnectionManagerContract — the repository pins to the central/default - connection), `crypto.services` (HashingPort), `cache.redis` (CachePort), - `view.rendering` (ViewRendererContract). The plugin declares these in - `requires[]`, so boot fails fast if one is missing. - -4. **Run migrations:** - - ```bash - php cli migrate:run - php cli db:seed --class=UserSeeder # optional baseline admin - ``` - -5. **Schedule the outbox relay** (e.g. cron every minute): - - ```cron - * * * * * cd /app && php cli user:outbox:relay --limit=500 - ``` - ---- - -## Configuration - -`.env` keys (all optional): - -```ini -HASH_BCRYPT_COST=12 # bcrypt cost (crypto.services) -CSRF_BIND_COOKIE=csrf_bind # must match the CsrfTokenLayer bindCookie -CSRF_LIFETIME=43200 # must match the CsrfTokenLayer lifetime (seconds) -``` - -`module.json` declares `requires`, `routes`, `views`, `emits`, `commands`, and -`config[]`. Every env var the plugin reads is declared in `config[]` (boot fails -otherwise). - ---- - -## Using it from another plugin - -Depend on the **published contract**, never on internals. Declare the domain in -your `module.json`: - -```jsonc -// plugins/Billing/module.json -{ "requires": ["user.management", "database.management"] } -``` - -Inject the contract in your Provider: - -```php -use Plugins\User\API\Contracts\UserServiceContract; - -$container->bind(InvoiceService::class, fn($c) => new InvoiceService( - users: $c->make(UserServiceContract::class), // resolvable because you require user.management -)); -``` - -```php -final class InvoiceService -{ - public function __construct(private readonly UserServiceContract $users) {} - - public function billFor(string $userId): void - { - $user = $this->users->find($userId); // ?UserDTO — primitives only - if ($user === null) { /* … */ } - } -} -``` - -React to user lifecycle events instead of polling — subscribe in `boot()`: - -```php -public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $w, EventBus $events): void -{ - $events->subscribe('user.registered', SendWelcomeEmailListener::class); - $events->subscribe('user.deleted', PurgeUserDataListener::class); -} -``` - -The event payload is **primitives only** (`userId`, `username`, `email`, -`occurredAt`, `version`) — your module never needs the User plugin's value -objects. - -> Scope isolation still applies: requiring `user.management` grants the **public -> contract** only. `UserRepository` and other `bindInternal` bindings throw -> `ScopeViolationException` if resolved cross-scope. - ---- - -## Using it from a project - -A project route can opt into the plugin without loading it app-wide: - -```jsonc -// projects/<name>/proj.json -{ "routes": [ - { "method": "GET", "path": "/admin/users", - "handler": "Projects\\Admin\\Http\\AdminUserController@index", - "requires": ["user.management"], "filters": ["auth"] } -] } -``` - -```php -namespace Projects\Admin\Http; - -use Plugins\User\API\Contracts\UserServiceContract; -use Plugins\User\API\DTOs\ListUsersQuery; -use Project\Http\Controllers\ApiController; - -final class AdminUserController extends ApiController -{ - public function __construct(private readonly UserServiceContract $users) {} - - public function index(): Response - { - $page = $this->users->list(ListUsersQuery::fromRequest($this->resolveRequest())); - return $this->paginated(array_map(fn($u) => $u->toArray(), $page->items), - total: count($page->items), page: 1, perPage: $page->limit); - } -} -``` - -Project views override plugin views by default (project-first cascade); target a -specific plugin view with `user::users/index`. - ---- - -## CLI - -```bash -php cli user:outbox:relay # relay up to 100 pending events -php cli user:outbox:relay --limit=500 -``` - -Returns the number dispatched. Idempotent and safe to run concurrently (rows are -claimed via a status guard). - ---- - -## Testing - -The service depends on the `UserStore` and `OutboxPort` **interfaces** (DIP), so -it is fully unit-testable with in-memory fakes — no database: - -```php -$svc = new UserService( - repository: new FakeUserStore(), - transaction: new TransactionManager(new FakeDatabasePort()), - collector: new DomainEventCollector(), - outbox: new FakeOutbox(), - hasher: new FakeHasher(), - identity: Identity::asAdmin(), - cache: new FakeCache(), - audit: new AuditLogger('admin', fn($l) => null), -); -``` - -See `tests/Unit/Plugins/User/`: identity (registration, duplicate rejection, -weak-password rejection, authorization, update/delete events, login lockout, -rehash-on-login), `FeedbackServiceTest` (auth/ownership/triage, forward-only -status, rating validation), and `UserSettingsServiceTest` (all four settings, -round-tripped through the real repository against a stateful in-memory DB). Run: - -```bash -vendor/bin/phpunit tests/Unit/Plugins/User -``` - ---- - -## Extending the pattern - -This plugin is a template. To build your own domain module the same way: - -1. **module.json** — declare `solves`, `requires`, `exposes`, `routes`, `emits`, - `config`. One module, one domain. -2. **Domain** — `final` entity with a **private constructor** + named - constructors (`create`/`reconstitute`); value objects for every concept; - record domain events in the aggregate. Zero external imports. -3. **API** — a published `…ServiceContract` interface + DTOs (validation in - `fromRequest`) + primitives-only integration events. -4. **Application** — a service that owns the `TransactionManager`, collects - domain events, writes integration events to an **outbox** in-tx, and does - **authorization first**. -5. **Infrastructure** — repository (DatabasePort only, optimistic-locked; - control-plane repos pin to the central connection), gateways (vendor SDK - only), thin controllers (≤3 lines). - Hide concretes behind **internal ports** so the service stays testable. -6. **Provider** — `register()` binds internals with `bindInternal` and the - contract with `bind`; `boot()` registers hooks / CLI commands / event - subscriptions. - -Copy `plugins/User/` as a starting skeleton, rename the namespace, and replace -the domain. - ---- - -*Part of the AlfacodeTeam PhpServicePlatform. See the root `CLAUDE.md` and -`docs/ai-context/` for framework-wide architecture.* - -## Tenant profile reads — `TenantProfileReaderContract` (published) - -`TenantProfileProvisioner` implements the published -`TenantProfileReaderContract` — `fullName(userId, tenantId): string` — in two -construction modes: **pinned** (repository already built against the resolved -tenant connection; the listener path) or **resolver** (container binding; -resolves the tenant DB per call through Tenancy's -`TenantConnectionResolverContract`). Reads are best-effort and never throw: a -missing profile or unreachable tenant DB yields `''`. Consumers: Tenancy's -tenant-selection flow (the JWT `name` claim) and `UserService::find()` (attaches -`UserDTO.fullName` when a membership pins the tenant). `UserDTO` also carries -`avatarUrl` and `permissions`. `UserServiceContract::find()` accepts -`bool $isAuth = false` — issuance-time lookups by Auth skip the -self-or-permission check (the request Identity is still guest during login). diff --git a/plugins/User/User-Plugin.pdf b/plugins/User/User-Plugin.pdf deleted file mode 100644 index ce002d10c5e8fcfba00f565f881ba23b7b5b2e8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 386726 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 z�RUok-eBU{b1P=NeA$P<kEG!ZhKP_d0NO??|qwS4#SCkts<*5ohOLhY6NnJlzpy zAi~_Gb!z<H#@lfHj053YVwi9g0Mnr|*lNyE$<XtL<uf!Ku}1Xc&#ESWz2R}<U<rA| z(D=ja%4wWd4+U9}C@IUQZq5G6Hq<1fk4xbTdiXy6X?rRdj;};;W)6ToQ>3-t%*%Z( z$>iKUvxw4T_0cxLj2_wUiD}o2|9_|N$VqMOOqFaS=|?j@({3o!Zi#vS6c@7Hu#Ee8 zxT)D{8cmqB>-Pq7WII>p|DBk2VfSf}x55XIHew<<|Jiy{ZB!}mnq@+>XR2@rmC@Cd z_TNfNzQX>}AC)88xFGA7mBTg`#S=D~rZ<kj7j}~qO*Ik_OUo8%#YEaIA@83eL$q^6 z+BKs`oc>M({wCw9qph|uxcwAqU6l@2ce!KX7~qx<%CyDWfVFgR*2UV8FYS|+5Be9B zRqz$qqUK|EVaNq%2fwfr*5qBQNiyDMsHoE<@r`9kE?rg97f@c#sb(zOgLA0GODU69 zmdX}ToG#9mF`?z0hAv=37(1ZdLi@9Im$;<^6zx#5s@)#k&UbNi4|CyQdA&Q+txW4U zHaf6chI(S2QC4j?395$uSKWsp+Yf7}U=1JHdYi$5m%PANwPNC21`GzE`_FiT;k*Eq z>}X6p#EodR?2!aj5XRVtGU>W>Ju`8ufZLR!F84UmvD@DrYzNLXR*AL2jnw+i>Vr7k zMGc&2l5Ni3Zrr_;!el{nu{6^8Hf%y6TdM#^Ig6tTW|;=7=!UdUW1bZn()}efMND<( z^@*1rO-nNJr4)4!=)x)U1a%+qmVJSE5`9yT{<ioIa3?xnQ;!nsysQ`7>R0w7Nc3%B zOd3WPy6sZfsb)E~%zPWOUmp6&IgngtUVg`JC*404!#P8~NZ);AFyz#Kf{%q(T(Q`o zoKVmqYB@vHdazPQ#80eeJ2#1vZ+*~~@u=)zv~&TKu#=$`BDjhn?wq)S{?)~BR}o@% zjCMS}pKCn+qj_VCq=m9Yd_}IUOOnmc+G<6GRJ&V})uuOLGlr3*0oho>jC?qLQm=_O zkbUV1q>3@r<RlUGg~R&~80f`1pt|r2@h2O}`<hTD?mLyRHpIIxxHjYays$9*JKhh% zdO!9+!%QE8z?1%N;klDke&v9NYW*sK3IzQsklv#xR9C2TdNl%z_aSH+GmIXMfcU+R z2UOzd&a7B$npleBC)_Xqb@3D2Dxe@ER1P&b#d?iv7nrh!PF<x%(d!>)?4QqS3L5&2 zL>cPlILVtmoN{bo_CBVTr%m(oBVc5Q$PgG98t*}biGiuBVq;`m=$xK{Q=u3RH-hBT z-lXU4Qc$$M>e4R#MHV3+0|7HH9$T6msc8oKRVQVsUCg&g@uw^oFsH|-2dAd$Wpb$w z`tHY9A;qbw9fI0oFOJtE=_9+|Q?I7#suL|~;comh_%|q<B6Ib*dSTuTPamj?Joe$J zuU__9GT74gunV|b20?L9u9O}84m0szzT{QHGJg7;l{0N6rlU(ylHQ>%aK2+#RH68T zKRP7He3Q&S`lK2>eJKD_r{?d>149ivPV_;HqQn^|;Az9VXYu}{2psm&_)Evo&Y<j* zFMaNdR)_ZQwjOa`t~H(KD0KBt`_|eQ!l~O#A02Oa{YJL?XWQyUg#B;RY8~gQAVdr` z+$dv#ka)dwj)t_bxA|F06KZ8G_A3;filmOE)J8lCEL-hx*i*1_hk9!sriQgGNO{>c zML*SG*y?++4xxpTtc`3OeP{!kuJtcv)I{>bT`Ze<cS#s?uu0_&cn)C?d$_>aR7h6X z0~+n2T3&ZgAZF|MG{|Jg{oUYvn|2p|-(r$np>fI+q%$kY-|NuBDmI->0}G<1COVJ^ z7qZB!<;6zb=2xz35^~R|uftCojZhGYW~79|%MRCgS3M&LY0Q0pLs3*KWZ8Fgn0NKJ zYYr)mTl}g!`f)9_z(xF1Iw3rV4SU#DT<yeSc7N>xVF&%ltNjxl0*!MC(cHzJox3@^ zE$4S}<Lanw7@m8~R5I~CPCIGi^t^dIaZ@$qs@!Ar)`5vTIc(k{)B)qmjvbr?D#t0x z62vf&LsEq8@BRS7*{6Z|zsDB)&Hfq$_-k#AP73<uTJY8<O&3ziWzbaawZn)$eRA&S z(x5{y(!Z<b#uks(^u}Akkx~i=&6XVmTp0@$j=qQm{V7v#jkv!Vna;n)g>}UL;yZsE z*h`7LedGu`QSp_6(KfDY>?{4aRrY~>EtJaFT|T{gHmnDurBi|2zhN5H2|z>VdWd1D z5Qu`=WIpw-K(%nphsFs`hHfk?Q-d^g!L?R-jFdh$_9bVpVmBn3+-))cwWvZC%s?d& zm7ydDphdM{R5waC+~ds^8V-wNM^3qW35|0pCkK;JNP@HeB&bResYjHZbunn0f-`(H zaMlLzH^S|xUiu;YOB;?FP`RZ0pW{`$7BdG?>wE}H6xdvn1=S2h)2|QN_|85fDdtem zYQ*_VRtZ!RXM*K_SJlKnJgd6zlL@--M=ahGKt=ScO<N<H4<0z+Z>5{lKK552G(NMC zOJRJW(3K;zT;`Uc8)a5cn50+k05($`0a{M6`FNc6kM*4B^E8+Z^F|!%&!)~_<R1>4 zf;2}_OKBp6-RJf8WL_b3IzkbivMKaZvcrkb^+anI;{{Qe5^WGK{cjAnkdCm7^i+C_ zRI!etK*pMx&#QMOO|GEG--TbZicxPWd85HM7!g(o14)?_zf&(d7n4zS`#j7eWX^_3 z-=5kT2Y*Hy7Pap~2C!3c+_P&MCSyJWqHeja><gZ=bMHw|jG5=LnFK|Kkp|+#Ma|~2 z5(rqb@>1PMEy%Lol^>$zFSTZ9uMH}DV=?e--#&x{w_fW`39oaeJtm2jqw}YTFGCo1 zAC{fMlW$Hd6dl}o2y-TM>fOQ0&?3n>3}wmppzhaGCCcF+IvtR5#}oVIL&0iyi~5>C z1!(9P^ahn-XuF_?R3R`>wTP;?mfWvEy_s^ipN2jmFy8FO&@@~Zd<f_f1ebCl4d^tY z0Kba45baT+p>zhoCRC#jp9n8i1C~6+d61NN6n`AGOXQCKj^MEm6D@h|*+HZ`)P1l* z7yY6KGaz}}vO0$_e3T)?M)-KVa201`4$k+wT7d?J`gUS2SD?GN$0);60tU`RV|jv; znZ!d)f27=}#NfQJ2FlgFsnZ?kP?dHyjhtOS$+?WnIpRv80yt-El+4CRlWoq-%D>0o zW|pMMu()EXkh%F&W$g$C0Je%H6(%JN)wmW0D@@gAg7h>9c8m0Wi07TGhsAhDz~hK! zdE8`r?9pg?;&Oj$D}6=-cYauus2~w~ZmbQRMF@osPJ|Olv%0OyM|lkN6>=q_mFKzi zg|ZdkxwK6_J^kuqbA0dFWoUefnocD47;HaJ7O$cxhg-OF2PA7O40ig}g;9hSE5qS- z5Ti8h>1#c#uax-4iA(3s&P3mx@0`}ozDRS*9_%QB(6jyi7s!I#J&%*AT<SeWuCkFc z6)jKRnM#VfyP?2k^Vg&0*jU;}B2tl&rz4XyRWY$Vp_fE_By$F?$I97?=yhB+n`(=8 zYmycJ(%c}H-g>!6x3XiTE0tR9_h;)y<+^&Ma`C4{nK6w*VnFQM-JpI8o=$IVy)3PD zvutOI=sYGef*TtZxdj{bq^Y(3(KYMtMQysyaIJ`~zmR8@*R{IPsT-2l^3ZbBbqJ1Y zldo>|a6n`yJ9984px2vi9FBSzSt>0Lkh4qnT{OtiG#Vl7XW0M!;cxrn7+EJ25hB1v zs7#RoKkDw+a=t=WR=kyLcEP)pMyXt?m+Z=o+%rNzT{@RAKAfSo6Kf?y)ppa5L4RuV ziJwj2YT2~xtMDiew4aO$6PjGpLlS2++3({Mj*Z!lc1_v&q0w>Q$#w_Ri$~Yck}=J+ z<pKAu+ANr#gb@?)hKbUe;ykG%LWFcpA`%H#m251>bQ`AFSf#_-{BAtwBp(jDJ{?*c zAviV-d^6kf=i25WO3J|04_f3<y0K~ienr5a_ig^);4_lhGy}46sHd^S5t#;CL>~(5 zdd!ErkolL?X1`FTwk1cy$HEF{XiFb=`3w;(kca+ZDod<n);`C7I?WBGT8kE6240xW zq`riAfQ^-B)17|S{R`wFH4LV%EGs^Fd^r2Eq|T8^a&@_*->Xmc+#Q;x=JmO~zWK+$ zjk4w8_m_b7<yw@#x9+V&lSo5`VVwWt>nZac-~RC#qy+odD>+yzi1c61aCnf+CU6iz zXWAV<OH;g^M~qHP3JP~*;mJI48oZO4A-<YLSBfxA_c%A`KERIUPRFhxxJwX2r?*5O zH*^nZ-aV}-9*#8jc!VDu8N4@4ClyTgj+3y^ptB_4F3q+t=wk;QRJVS>TUU`{dt@LE zy)|AtL%g>#i6tjK&K-$wz`<wDgk~=Hweeqq;+%oIeE@4+@)$bdoYz{QxGH4vRUed3 z8P3BIa$i(xW5a%uZWnhx{bas)?#LmAzqx(aek&Nae~s22#}eKV2nt5Mez$)etjc`) zylY-b&7!@^%(WUu{qTBB7xt_mE0lejh4GJ%4X0s=T-n|k_iy)?IF@UW0hH*6M^N#_ zbrLKhp+Rh@MmmWq0L<Jjw)?yAfOUBO(*`BbXpl4MH+Hqq;9j2hkO;A5?~M9^D(gJD z!m#iz0@zMdUQF0qRQBH$kQnNba^Sx|lI(2PQRiTN^|PX-`IS;>`x;XTWy%=0`lf1* zQomNOP;gnn-Y&~1zPzcU<F*7nsGyIIsjNEferWl&2k7rR{_PQn^Ve0)T7m<9EY<p2 zUirO5ofM=srS`PDfp{kztQG4?b5g%fDNVD?zYe$7tpkgGvps9XzK8FDo7y+RX&6t- zbN!O%j&c8B(bwoh3rJ+O$WL`LV9vTdU|Y>jR`2njr+3Snn)G$<TRZBGQB7?&;?9+c zF(=2*MF@h$v)vkO4v1~R$h_RGPzEBhCLkZ6{o_2*pv_F^Q5_ah?!qLt+7Y!kn#uH% z8_rR6kDgRA@APiy<iF77zg`~{*Kyn<jGfcry<GHsTamq9^5(dBY$GwV?a8rD!ICeS zexBT;m=R_6bG4vlBxktO0rnj&NKPx-9u_Wb<2-%o5X3~TR<kBL`_lP|aYI8r$i+px z=zK-b*~)V=sy^yG-7qcqgp6wpoE`3%ba1>mzcw0FWIOpHeV(<MXkFqLE^*G#pS5{w zVdEE5q+8w`)-4?GS!S=F@psDZ{2<GTBEd*a{?hKX)`_m!q;J)>f-KI7aV99j;868U zd)uDi50mDfC4jgln0PBUBv_)oZ_vxNj*nJo$Pzl)RhW5%c3yZ3f6akG!fN8AZ)J}3 ztugmk#xHM2@HPzF<Wnb*3gXvQvAt$}58Q4ZU2fRQrA9Jc#|$p#<o`<u4gU4G+=RG) z=$bHam1~WRY9PMbAeWu-;4E8EfThs+2k0I*-W|d*#0|MN1X;zQJ`jo1b2vmdf<LJG zCJ--Oo|7@O9zdP9XpH%qs51+tu7ckR(JG#cM(ezTX&@zIUb*PCB(be>S_56(MC{9f zb32MX8;7nE%y|QW;tS_|kOQ@tNgO7i*z|WX3xNmBfmzLp{Cd(jnP!{SrF&`gvhB81 zIGvvxw}9ifj2B*(g4rkM&s)4gppmm?QR}7m)VjB2rVcN^{yLlPIH|d<nY?n3!<swS z7|Gu{U3CtM{j>Ioyf4cI+IuMRavZBNYuuX49bwqiOo^W=MnlOo=}L#|>7DBr$#lmR z1B2gcc52?DGXcwk-Fb|LHbh6rZSxly7<EDKX(SUH7!zzeQ!aIOcQZJnkn8PZR<_)s z0bk|NAY8*oWbJ+hU2dFzNgM)10}o45=2oVVM|{dO=kf}>6<Vv7CH!T~d_33@swvXk z?0#~pIUQ08WsUKbp<kNzf4TW+BIvk0$NwRiWoY63KNOLy|G$bzR_6awL|*<!5!q&Y zeN6qv+541og=B!Sf_cpWx(6bOcH_02O2A<~5PScRP*ka)J!4(f+|!KSHSlrKQ2kU$ zi9}TKU@2_*-}Rrqe}0ate*b5CyzTpZmx``F!K&MN|LOPNw#T0n_Ww@r$lV!rvgNxt zJqr+eZs?NMKxa3a7HRcrmcm@xno~9p`F(v>!T-IDq|*E0()oRCr_%ph)%U%PqW8Q1 z()axd_wO$C+V}CRSkEbHH$l0L<KiE}ERFB4YQmo{+h@eAUNFICJzrDr-`g%{{N84I zJ|F*ef391s#gs@-TH3yygnKPmwfZ)v2kNhFC{?GPFSGfzwc|+JJ3}^xVVf;*7Eic! z#ih{I(jQ8>$N<9sY7}&+SggG&;P&@76~}`fDN)n_DMFzSJj&H`|5%szoYYReL(mUb zgS5l+6+rSVTW?uSfC~0f-(dS{v+rPz^rq!qLg1Qz;22^@+X!of3&5yDr6~Pt*WK-^ z2I2oz=KOQphC$$%r`M_j4Sj9M_Qu13Q4}on1l${b2TXu~Baqq~z{?4Fxu_FKYydGV zY9IwwGSCZ*8i6ptn(9!4YA^m#+iw%#fG3Vn1-sx3u=cO4V+7s+Z=D4Q2{?kK4APh% z5Xzn$D`NxrNYg-Oe+(-+5fUim8-XK^A{nnT1q2v8?jg-1FaR-IZR;vd;9ejwEBL$j z_E}#KqC6SN(;x{d%Fqx~?%25F^4Wg`pV8iOFw@nz%93)Ik|o`{ZvKf5EsgC(abDIK zU+u$?l7!9#L@}1M@8IS)arHAM{>MEznPRd?L_x0YDCCSY?EI*5CQ=ZHrc{~n8<>)K z-f%^O&g%!~ja$i3I#9E}w6t8nQ16C|wzf>xQu_6)vp;t#&UEfAGeonJJ{QPUC8nV= zAtY=O#fsiU#9igM-Ra~k*I0L(C=&JgDBATc(DN?pZW9$i0p=KPu=o4l#J(RX60L*m zT5j=VPD_F9d$_5MXGUi+E~ZnG^>Z5kX_!x`9OmpN`k3S}KO<-?Kv7i@(Ys^yl(i4; zqp|*{g;B_yHgj3<0*NnNq3DdocLdOi;fV1-!q;}WEO19(cSQ|nPR9`h9mJ^x>cIhi z`j(?NSpn6Es`#3^focH(Z-0$Ri5iCj_2W?24Jqp110h`Uct#%{-U>o`-Ib%F=8D2o z8lP&0W--dpG>pZxTFb|%IXUWz4i0-u(5j(twO|pjECYDM%R@SdI_ANH1e3ZkqKjCf zj5v#f(l`f1PpQm|su$AMb=9qeU+3CEu&78S&r1hHGAlw#7OylCq<G_sC!{{sQ~&vf zk|LK{`V)Iak|vsEqP{4!oZ@~2@PLX*r*5qr!CN4(J1<;!d%WHWcfMTSP2ua|U=|y+ z`4^*a9A;kY`PSQh@s_bd{xf~4wegpKxd#7-jv0FG?r9HmqhIK^p#Gt~=ePU(vP4h6 z{V_A~ce~U77c>lfG@Zr|^^AV#-?#tH{kmd*qx0T%lLYi&WlVWO)a&|8pbpB}<waVM z(LLq-w$55NIPI_UbL+#``9}z*-WCd@>AMmbz0_;0dutI*20roYX9>i<wLR8@Ip}2n zS^{6<zGCwMbG*w3uzGjI#OJmzO}$D?nP>MCz5E8D?uZ%sb#9Tn^8Vf@xZz%6>1~ht zJnUIb`3kgrop5+yQytV%i?w<R)BYN^3SMP{h+b0zSElYg;Jdr`BicZHBhToc@c3t# z6I}QF)?Mgg2VIq*CT%((Vi<Bvo0r2{X6mdz<7p>7+x-UZm8QG#SiNdjE6R(eQ&$;b zsPC4KCQ#eOhe%B6&yNYsl0P1UHe(m>G(stiL$n(sD5I|t@y}x0SL(w2%^=#Cb$7jD z=WB}pXxnc;bN!y*Pvz6QGr-Pbc~DxZb$L)2;;bhP@Oyc7szb@hfB+NHw6r?$A7bSe zdqgxxS{;hXy?g!kAka@#GF=58Kbp-56t)u@sWqa@^E_f0UV>}{8=vNAAj*Pf-ZHCM z5RM4?knHq&fkSixRY>24ZX*DL7cd&b!2Wn<tgag?A#L0MbaDRv^&BoY22=OWKuz3N z+-pbvy2Gk9L5xnNk9lH@O#`g`EiZU@LxZ>=M{e?fI_S`P?UHkFjS21TRJcI`SYY<X zAby|$v}GSNn04g?t@01UA`7xZZwC>Lk3qzGef@{byxau)#h6AwCf1I?QoZy8(k~it z7X#LyVxKu=P>t=Bb4u{4w**YXKpgcQVqfKWKwUVtk05(MJ2xVKjti)T?O_4sG1swP zcukF$Ee~&3H;qj%y_M_k5!!GDt~Fi~qLp59x<yTn^YRD%%G`uVT^ah~V2hro2wImw zh<j(WN8DY#Sda5sFlFm)r*;32vt%3eWio%3_;mtf`=+t4FhUqN{Z3|IgF}bCsF$Xt zSJ&n^6W6c4w*oDFZ_{s~ztwoQXh96MwTc-&*-zUHZ3;$i_Au<p-HZ!k-`{*z;-?>D zw4^R{na1X;TK3yY?GASG<zmPdECg3d-=2dGQ61cDyRR`yz9!{(Ee95zr&`VNKD>4O zFF5P7z^d#v)(u0CE!Uik5LcKE;~)ojyu^3f(8#ct^$v&X+t9*`nCLc-Mm7^RSy2vO zF0ZfpiCqWfqq4o)Dxc!Er>dCfro)DE!w5p`up<Pu<D=$@kKPU4^mm0QENA(LZ}R2m zk#Bs9w1@h-l)M9So+<*lLmoGbZ}aCx;A2`(y3M?0&AnE&C9_M-^1Vx~{JjgU*8l-% z_oP*P@uUTEmg8)j3^tiuy?N0jwRPPD(!6aA%3$*;pn*LD5<=MVtM+F}Z6W@xEl=Xf zOzqvq+inED{M-7G8w8iXD+E{X6~>ACnwxbVxQ%%pyW5OFs<LQ|w*Mup3s30=_6bNA z<E(Wigr}`d9<n_jyTg9nRPF0SEd1z*tX~=el;1j2^Q6-~%mS>*ztIT%Y3#c(CiBE4 z$cZ~-HjBn8HMBVL3mt6IqtJuK5c_YtPbkky1n1Txx_*(4&jCx4HT|sZnVh3dW7${Q z%}>I=cnHlG#83Qbvb0h63iz3i1A&#raaNCtRV8Pud}t-h{AmN5Rnvv5VXO#UhWeRr z2A>LY`&8M*Ve8Q)ogtME$*elofn!-=L+XYN8F%K6rV9fi9b@WROeE~T-=|*)ynSYd ze>8x|N~iMm_)wB>>ihQviAfAR!Ss8<_QtKQm4#KoG2}3G2K%7%)3WQ-i5<$um{bTm ztCR>DQ?W}Erw_2s`EFSlnU6p<id*Bw>G-(0)SK3)^QWO@ULNpV-Zpo&s^RC3E9eE6 zV<neI{l(wHmR*I2u>@#H^`&#sXy0=fDOd5cR8m?8lGVgg;!W6T`qil<wD@Xp++x&B zmdFVA7K{+-p$qpm64Pd^59gW^-qgQVr-;!j5UYNsH#G+T^w~A|%A4(cA1kx$!Btxz z{F+>J;i#>W$u^nV$CrA();Y}~Uu1kVvpusx^)w;2m-;8)G~-in#@I4hBBzCMwSxEX zq4(A(iCpDl$6;%iRT{S0W_g(Yw!Zjs34-+31IMt{_-LCbB}raj;ICl%a_(tdxcEuY zo17YY=F$+6(F2Xl#M*X4y4Iw6q}dDTB-wT7Y7*o4gsI!B-3KCPnk?^=1A{mSz77J0 z^rt*mkr0Mx3Sqc>gZB+mV5mamK8&8w5>U+18)QF^4LuR3_!a2+qD^eU?;?y5Hbxk| z_W~BSz}ZWYyGs~fcm-TT>Kf_uphJUxxo3fe?j0CWd2>0={OndLtS#>It(AO^O0^`3 zwvR=q-F%74`KdP}Y2bGDT7oxojG-Nf*?H4&n;F>G=?L5wVckO8Bj6KFj7AI#*@FzR z@hGHL5m}1<q@3^hTA7*Y<hP+Bk9cE;+h!N%*sK?piCW>=VZ1EAH#8~%wV&R9s86)L z(>ANZB`^<#&?@<3i>@V`UTX4WnAXP21n0h>!lq~hp{M23x(n;4ERl_1E_|kwh+ERI zH?f7Mbi6+n-_DtF|F`kth2#8B0;c*?D78vyrqmo(vPa8!G%U|%8|LNHN#Jby1np7v zpZn+2$ui#ig6s%t=J>gb{l3jaWF4dZGmk8bXTxEUj0cW5*QLefho5+|Yw<B954_o? zM|!#!n1q%ovyx#Z1|a*@fh*-&XEG74U-0p%3c3D*>MKWX9~!rK3V9P3a8oVgEs{IO zc$bTY-se9IG0;(=hM$#zk%%gI`JZ?&T@k<4A`CverF*@98o-DK-cDfz8$F<by+XqU zYFWk_Zi*lfggffQ8rVz{qjdNB(*(O#@*iyMPk6GwnCFMt>l@{x84e>FZ}ksf4=T0i zpQ$nfg4kcIPZ<I+Bbx1%goQEIM&)Ed4J~Bpm1By3HE>fD5x_>@r=i{fLy;+9G7uw| zeN}!4uF&opZHCv}Nc!2-%e!~4zK})O)jz(IwI8!x6`wFRM`Htc6H_d~^Gh~{Loi}8 zht<tl;IZ}V4Q<fDZodQ*i&5A3Df*j))**?kiPdNx!g+XC1-2G1h~TV-Gjy%dk{GGv zlFKbpauSy(zwf1}V23jFg~290R}thcju0nfO#V}}c>1trCjYJTYDSZe*eTheQ~Df_ z?NO%cOWnlPFw5fHtv|(%fxlK7yM+fmCwqrkuh6k_dZMl(LZ9uDKZ@vR*Ppp2HQeu! z{4w+GH_uG%x7JzOvlt6_?rQvnuI05aIm;+$R*CcCwMKcJxR<|_$R2+?rwWiFMWf12 zqX0L1<cboUN_tNv7{*yuC<ZeGE(j)yCnQ*OKbCbZ5%n(^*K-V@?z<19<~Cyus`Q+T z863b|d9$#^quf@CZ64?$GMEATlMNinKz}*>CvM080A!c))VD(G!KO&+C~-A<fN|>I zG|>JP&XS`uD`fUH0qFjfRkrvrhP!b}8shkDw;()#G5L00rZx6&RKC-=ng`4ocH9o| zUr{w3;Ho2bt>BLU-9iU9UgW<NG<q|utaG+l+&Q>`SW(^FKPCe0w|N5>pNpce@dO7~ zNOH{M2<Dax=a!+a=FAqd2<WLoe?PI9Pvx*8jAO9Yj_aT(&q!Kdp+B6c5?L&sTN3Hi z@)Shr<qGj?j>ylS?5=S7t`2qu_%w+9QxvOQeS7uDSzyP>%Dv{{7hHmLW4MKt`hn6C z*=euvbUjV|zHn+Jw~fo9-0*?n^7hP0GDH1NKn~aabMEP3WvR>FIE-Nt;$x#&PUY;U zVduU=Bp|<&850Y}urmlVW2&Y8uOb&PzmXDa97(X#4eM-+ea&=8bdxyg*;sh{o$blC zY=U>H!V9_rJp|w3eZwu_#m)bc;Tk!tTO7ldBDo!*PW&kQSm7Daq52FEUP4bP56x&# zn1uc=KvX*_j|#8IZ4v{MoRtm$CcO-OFZ7=5Q8}sNTTfGB62Hr8O1kHak*119=&)pF zoX)sn5ELV^><f5M14~nul7K!{7NlC46st?zb1mm?vK}7F&fzR&9+s}vZF0k9Y04!9 zZSHbf_Z@lSwJ?H(dV<Kip4EuD50NN)F-`9@aQN_+?g9*X_@+0AsP^`K^5i$}19Iyd zN15EV3&{prB=U+qBX%<bdqXId`MyPOQ>9C_>6K?L&CR*_OsM!gWLBo8QgOQJ1q-)o z=>;PR*-pZq#5T1uh>=3Xls^6pD0Hf)a4!89>@$E%ZlvO<_I<PiE#)<q<;z$$QHt^L zsz%Eym(>inVMUdb7W#7A0$DGzqkLJ$2By&)mSm?Z2A9w{_zte2$?-h2I~=X92iVDj z8(d`p42q08IkX1a1_Y11;$zcLkDnoY!TD+cm;%J{?sLI}oSyUqMud6Nw<x}d0=36X zqTF9mBtySul-JG~BlTvT5kK?Id0AtI<4#Co#1(b1_Lavh*TT6}7LltZ8!z8jBP)JR zt926CTjl(QV0D|SVZ|ROVC@+<VKJYvJZB{U%v*~B%hi`e+VuQHu)3r|{Ed0}rTg$Y z@bTxm+ct%J49PvXl0G6l7h~@|^bB5J0blGUKf8s8jK>1Zv%=ys%dDh{??pikd}qDO z2iG}sMxXl^y9zh9<6FnJs52?WW=i{bK0|DKvSxPP1_zmrnJ?2;Iq`oG2IvLod#m;> zn2D7xK&-v8h?(8#bH~*kT6q`^m)_G@VYPYsVm5a^YZyj=a1LL5IkFv?x^fwr7*>!b z9c+H$O+Qs_>mN14mbbkZ!Q9dvUa>x~WopNg8MojI^!tv)<!h7WEsAD_#n{?0K$$3C zVy|z22%q;N%GlWX2jU{On<{yIum+m&U$?P{{eTg5{Q^l$gYAJs7u;QeV~HJ1RNxpM z;Eq5NNYmH=!Ch81j9P!UG!2M20O$3;ZDzbc#V1Xr1e-rPt_QRPG2GTh!!Mw<6AxGB z6WVAb@%#){qzTQi0`t=G76?QMcC-lFffqD-lp`TlsWK*n?6(_BX%<l#lGn)W?ml5B zE>)hV75jT>yTtt6(XS>PopWi+ntZalmE#=io3;+k$*3?JDrN+WvYuauJ5bk*s%ezx zfGFFBv9fU$95W~vfEQ4XN<5mGVOS(;Mq8Df|F$ec5v0;d<vb_DYhJyoKRZxa)Y0%G z=1<OEphMSPID@AT^y{#cZ%Wf<tmZnO!b3mH!j{2R4#Fuz%-{R$%x9(Qk0zX=FGp90 z4fxeQAJsR#I8n_0W9HV}sgbXs62?Yub{GhraOtiFaE9udg-4(q!kJ6P@I58pl;A3j zQ@6Kp@-GK>`qUuz&Kb1kx)IsRIm4Vjt#Q{utmYegOy?PH*k9IA$UcO`c-$gt=uG`& zNPU{iZ|n3XB7S#5!#FWrs?FEx8tSZg^SIGrJ5tc%H6R)hNtbDFVRO!5*AOdE;qu!` zZMjxU#np;6OKT@<C&yj%SgNN}Kxk6!kmN=(ZMg1>gldRfw=p%uW_lY$?{?Yd$h>uv zO29g~HXPqoLN$?1stszudp{ar6SWz{K)yNh-;<N;!^?QwoMVNm@4UVkRE?6ZNOwwG zG#PwpEuOncX>E?d_aG*Ukt)tQ52nw&)C%{>R8b;$QcTdFVTGE1@~}Ln7O?d|w(upM zYr{G`FsA0n#~;WAlS{mCj;-XE)tzd&P0y-(n=<kV=OHBjJ~PEa>AJXPS^M=(8J_Ax zlC%0{o(+zS7@RFv_%&W<rpcdK{9alwP)3jv(kO1#mS+s|4<bA^`cI3<ziDAU{GmK& zO0XHTvv*pAYUg86y75$2SbE3I#lYmJF)6!dN`qXpmDx6<GZ^kzuF-<zqb+@)@TsSZ zv@v~q#$b~=u7gg<4m0+krAgQ&n)a}D&IgWkoNc=N-q2tVG3t81m&;A5NUTd3g{K@M z8J>N`w(6^Fq*y+eeuMx@qtMSjZ@=o;a0Dvc<;#}QCST-ajQaqC1OF?=n40Ge5-+JU z(uw&aC@DQVCO~~E$(Q*D)vsq}KGQqCndiG3zL_H?uiWx=5n!|M;|o)$rOpG*pW}`= ztM1jP&q`pTm$$^W48q4Dp7<WR%<(CR8p~J!F*DiHANM2}#5Zlx2YjESBzKxW1xTQ) zXl%sDQ<XnT;d(T$A7wG=_ExzDNgEZzR%j|;UBkx5ga)lxs-&M4{ah<T7MfBfc@SI^ zB62lOjTS(Sxr!$hHCM?m_^Jc~$xEO(ldKFMjk^2>8%C(8ypm#37J1U2yiKZgCziEb z;y$X(bxu>1UtE&Q*?(xK3G!Ti<xPu!YM^%JoT;-91b@@>)cC2xPfDvhGSx)(z<^oZ zR0}?unu(Kz>0g*W7tL{Z8H5(sZG%Dc*fXSocD$*8buYTxi*{_ManSHOyK75*La=Ak z0$@8e^3UF@wn4u+w#bK<$T*zdNWYPs$2@&P%qfCsXU8&l<C((QncI;SqnVc3nfDPK zqnSyCeWukFWsV0xU_i<WGp~EVqd-CqQb8CALk76tWIl{g+=`gM8G*KqjOs@q<>y<$ zHf+|jAUlQ=zmqy{7|n7So*@M<a##kWzzP;FGQ=!{7`d4cs<n#|(7+^yN(Wq~N>LQm zKY^9&6pc!|FuNk(QBcoYD|>cdc8Yd>r(dJNekzDbYO0b6duqvfE92i!LC<s`)}vF# z$?|#ZXn}euFpl!Qh$GyklYsu|?TEU=?CxYiZeZ$8<LjcQPB*<28PV5dvn<Ye%ucPb z{c~VuGswowoA1DxOKsM`@ezL_$>zzBOoT5tX0-y^5aHQ{?3SIQkT&j+aQl_9v_1W6 z{Rv*nX9Zp@N}=R>LsP;frErD!4z?&$0j6j3O*a9(Cu(Mtx(uCR6w*AjgTO?TghvUM zT-3qynt0OaS3#TyU%O_O=a+G@yYe`914nTq_ni337M&dB*4hxDy(tQ@xg|;&x&zrK z_)_ixv6X*L{Nzdm-+z;C*spIk^5KW85sEqf7w*^>ReN$8#gLTdTa?LyYHcW5gFDd( zEYWH>AB}U2DF-{Gnh_?Qf}kGFfM{hM&RDo@23|ev_IaMRNku_o;&-d_{64QtwGeu{ znOECvQ|+of-2jyu_+;|k!jz=}Uz#0iCDbw3<X%Mj#tTHI5W!K_Df=c$g)=tP8H%FQ zzQdMe^TrC5SVl@rjFGoi%-(H*8LFlIZpCVonE+;ynM!)J*(s;>)JGtGmlz<*lUCN| zTvc3U&RW@GB-kfK(VNE;%*!;j!lU-W+vwDFjnUg2X(Yi<ED!RpPOfu3FDDX<_llvh zsv18wq@~x=<cbFMkL8bDxV}QOTi1nqW6#uS$g-TASJ3PEUFWQc+!vJjQh*R%m(+)w zL|4{;^*?UcSnxE+5LmDkZUu5e2$?<n^`^ljP_$Y^HEw}BR`qS>u2z?+AyXO<|7R4O zQM^tsZ0XH99k9?;PMODf`_Da8;P!r7<ab_ZHZ{@tYzWeT1+S9CJ&ipB9uNed{})Ej z5EM$Ieh36ydebNHue3mS(pL$%$q*kK5bt*d?Sry9K3D7u{RJ}d>c@{RA`1VXLae|O z;yCi0jeM5N1=nA^LE(78@)EtR@Tn-9OqIcbiS4TQBkcf2@)%0_<1ks{MdQ2Kt;XT5 z5$_^fZHpgVpl0akT1Yktoqpp4N0EQgA58<t?6avF!h*@OC~1sOpF$==u#yxD#Sn@K zv4$^@Qqsx<3qjrS>QpUQ0&9b9gRI&q8p(K~sM7XKkw93bVm9sxP^~DAmB@2}D26|A zZ4<9>7vD*jcY5lxh7EFFS526fJ`6^F5%jlOjRy4{FCHn)a->C>5k&n}o9DQ+2Hx+} ztQ;?$v1A`r<UHt*fL82o<MAI=vlixe3r6{Ci(A=2db>|%x{h?76T)j?VajlBnRf>L zBf{gIcMk6Q;UKu}_v3}VbHs$i{a1>gaBJ!FE7#?-jl1ist^GwPk$Ik#z<QyW8>;PJ zG2+dlTj@&pl9Tzu6gRj0<&*$xS2+9K_b;zm!!1)79m=0LFlVq+)JujX!^C!A%(f87 zc3|MPfOq>p9+Sw;T^;}Ux*@e1daym5(@h2_>#8bwwGvl76rOF4E7!Il%ow*()(JZ} zWn8=l>|Q=tKd;UUe_v1yrrEGVK7?Q1(OoA_xfU+PI*555Si1Ii9cO<Qq9(bay+kfn zOAf!So*Fkx$E{wW@%OtjxZep<EDNpNh!ae78oTG4n-G4iZyOLZybsl;PsQKhw^lzV z;zD*HR8js5_x`5`8K#NMPk?Fs#fSFUOS2LWX9<yQ+CpseDu5NpWVQ7Og31~h=KU&w zv$v}Kefpah))?5LCxxFhaqYB?kB}Nq&csI_wwh$&e$CRn1+3OF>TEc#+u#@1NvshT zrx&YJzEkdvZei_eY`6fA^oGFjJ>g&ugGZI11>30pB*`eq#mEey_MZ_<s)P!11DI8< zHE@F4{{CwH1vH}s$^T&1gch|>m&;)#P5MhN{Rnl52WH>~Ji2PPOwQ5UE%(asCKIsU z#7s`Kf-u;$MYZSJ2n~~lvn%Y3)0_ee*ke&f4s9OmIYO}muG~u<M+gV!57@!LG$YdF zbO~bokWxDk6m0PzV(>kPIH{$ds8m8Wv`msBmv}y*pKKWJXHwz;yQZvr%qp@PVDjTq zb&Ku|pyu*-OZ{2tB6p1rHXGsVwneF=hzo6iAu3z0K4&Q_{PfVs1wRs%5mM9_vaYfn z$v8rR3O?b&QaO`;E=njdZ!KO|uD&YLsTU_g*ku(G-`Sjdz(q4s@$d_Z51J?~zwoHO z4L8K91cd>CTAqA5`Y4-_nQo`=_TPvH*F?RNz0Xi&yRT049<nJw3+FdNq(Qc1*yHEd zB71fmDr3K{Tmi^weWk#+A+yvH7tptK!$WRD3uF&W);4+KWYWJ0rwIUwQS++QR5g<I zM1ZHH^$6zqPcWfXGz<jDoSr1QZqlBNeO8gqAnQkzOq~&b_e#F(M8`fC%;&BcqLGkf z4IltH&-`xdn}hFfB?veJehTPXRMrzlGZVx~5=2K4mc70SXeP5-0f!G!e}n)EXr`e5 z0uHR;f@lDRrc9^D!f0?3aWNI!)9j*lPEnQs^;5ScUr&Fcnnw|ls&h*DpLxHDD};y@ zRU*ZnRroR(3QAcnMRE}>CGUR)CY~u*F|ceOq~*V`*iUt%^CcP5;c&CG9oZE>K1yMr z1?n*kq|8o8ui|=A{s}2ay{I!W`Nc2&^Qw9EdB}0Krg1gL^M*WK>zFuMoewP#?~Zj4 z+uJ80cu2JQoDaTSK2G=S9phPu7^&2hR>vs8L|zM4bZ<LvQ1yQpyM`c9f^9jr@7T6& z+x8vXwr$(CZQHi(nLGCUdD|av@fIDOT~QU?=Par_`sB%!PN;O2Y#x&`NcN3ajoo?0 zucBk3Q80{;YS4Th$*vo1^C7wNg!4pwrh(VrEdtT@EsZ9$Z%ij$K5}xkPRh;%wk+h{ z&f-txbnz&umPorElCVrL_06kk=1S;M_?uG@J}=7ABfvamI*^cqE3r_?7Fwi6(@>U+ zLnToQ#!lRwF&6jeI3hVmwFDzjE6)z{HPoE&5J4_9Bi8`Sup2IE7%enqNs4YelAX3H zUF@NH#THVw<|Hk`(3H_+@a2!wEC`0Wtf}~_S{XB5uyTU0<JI7_`?eiL#Z))RMZ9_4 z?N8x4YYOQTQN-(_0fD-xvX!2zs;Fj<8e+{}b{0QTWpg}JP@7<73jrQvpCLseiZB8B zDZGUr_V5W?j@&|u0hs(LNL~zw?Eoi`rEfPj*e46zDn7%11G<|O2748%M}b`uNLi$x za`@ZmEcBGRutWXZHlQ4ee5^Botr@|k5M7sD21ju%%ramj%o5PHR4(cu$)yophqJy= zH*sD+Y4LSJzFe+(6p&M4s`9D?t1uLmGeJ(wdCGm9A{ryk9xWUn1huJTWYG|2x15Bv zth7i!G59EEE{bkT8G=DWxuWPzMT#Am)x~*Ikfu4US^vWyt4RVJZNWnkRGB;)q;TmN zXX{zRRe{^NgQ&f>evnI0{HZkL@H9Ha7!tUGeo=}xc@P7&XpK~|8H6XceOWV<hzih^ z-HC|O^&ll#a@2i{?Z(uOivhCZYN0>!u_%Ij>KWp=$5YGd4tyI89l3E%8m{%n9lN#U z*glBFohlY_WVKg077_KWnqPG7KZh*-s82DIuy-7i=UBvQ3@OMpD5Ib)DC2+)sKXVU zc7+E`&(<p1*GZX2wdPU9>Te#NG+`Bp!*eFeJb6yJMG{40AQ*o0;EcJgL}@V)_PNxA zm0a7(N__8>Xr`K$TuU|y6FxQNTnL*6`}F2_utE@zqD2*+_43?Ul_)}Z>iO)5@H3V) zrtu*%koZOTq^@k=S9aNUd>qu5QhnMen<fS>7e4}C7^8omJ1B^HDoHE^Ai}`oqJUja zJKfRHy{Rz<v8go%(On;h=wwOoVmZtO-L6XzlMY&D6!79r148rc)n-We@-e_h%J>%@ zm(=0WJnV=Q&VFBkgUP^}%M@(jM*OJdn#5uA8L8dcmDs`2Gkj}HmXM9iHI5}EGqH0b z5qJ9fd}U<b+UoF3wU>jq!6Kfd;X*^gh-HW%epxw0ILA>HNrS*t+$i{W0CJlRNMqg@ z;z**MAoh~Ij>N*%z3NS`tNhiNxr|-Px!TPxO5xu{TI7KRi{<bSFpot#4D)?}CTN3> zhctRKKlqyw1)mi4?LcCb^j+Qh#=VD|nQjnkHs*;!Eyb7&+RHhm<ZS%iQ(Dldii_+} z?)a5M$K#w>IpV)hVoiZE(X;x>_@l}sSF8gFpPh2VpnM4xG{8{#8ZyEJ1c6oykQE<Y zyUTcdLEB}B0h?uUsKSfDgbN6f`aJIg3b$%e{-AJ7iAA!Uv3AN{lKWoL>X-$dQa8d_ zn-zV@aPHo!8aJpTLbyv>(t0M1%lIK#Q|G+68~5QqA9ZLRb+uZHG;+0WIXd=VC1V8J zgc6lbvs5+U<Ogzc!b3$l_sC_K9E2V$NFu^iR&p9O<AjR3O0rEt89w%euB${Dh$ER1 z`l68$&A%@&$WeTh#+Y&P7S%;nXgz%K>O_C><^tr2<qgA$G8E1;GRrp!kD_Zqwp+EX zzQhZkz^B8<FpAIF4{Pd|snTkMg_vdFzUBmN%~qk)MP2)(`|N>+Sy_RnIvo)Co~ebg z(#T5<;1)VMO(t#}???9DZCU@HZPYX_ya8j~(<ZBohi2^@)s&-B6r7haO76gN@T@yh z;Kp$H&B6xSZgFOwXTo{6*7#Nd5lYy>*@JFM#dvi6u4>9g7Y4y~NA02shJiIl)gnwr zoeCp7t#ju@PYkvohxzRVD(cg<U8B;!=gDbO0BL`A?bBH@5ht)_374}!f@K-0p>Q=F z7ti7c?T!{Ag$=!7p7{s?CN1hB9hw5^8{Cyc`?MQIaeqr9x5J~z2%?!TEUSBBO#^*U z1Fv~jdhq&J{VHyv<enbjA_y*0?Fg)d)LsjCp3!DsB}wQjG>QZU+l^SuRFCL?YaQ}U z2|?qQlT3?^qOr=$tL({}Q&Aeq#hISC$`9I`LP@idyh%Hrf#!v;b?`MG{JYBEL^PMO zSUy&`$pe?VC=-9Fm!;Kjx_5=E;mBbtR2HaVE0(cm1!`sTwpGWb7-^lEZZAZ1^lFZL zQ%b5MyM^8uvDwfE9Ynn|L#tQ~@}Gq^Tl-lUvrMSgT3c0)-WwnuLKQ3S<@<%JpAK0L zbk3@v=Cc}2Z#TPbwC^apv!1m!;%q(VSLZ%Dp&}l=gG=4~?YpV3F?c&PL#*Xrrd4x> z`L*oh<W!4jiFKrjX;`7BD6GB)>;1EB;|;g@Ee-eC?!_fKJfS3Vn6N>3a?Lg**sYE} zna*dtLKClI5-q)AYk0LWrX;3(x>K{b%Y~M&*J-u8p)T9@{a6)kyk$dg6c3@kF`%Cz zLkFb~p_`ch+%<}*W!}JQx7^LYg*p&@<bP6scTbKbS$p3NTJ5z_EQ;U0<Di?CpmC_1 z<|dZO1gpm9_;kS;ELij2-S@x}xA9Jmu4z{$IPiZ6z{k6;pXl!lP^rKX5PI+cpVZ*U zEOn&-`dy2{=pV=k6P^p0Wvt`@dY6(sb%}%|beY;g^<2RDT{t!VmVty(t<w;Nf7+0Z z0;B0UDy4`_0P>y6Qbnx*`)I!=FnPT`5(*1P)x3_xK|i^~=tAWSF@p4?#y9k11ZJ8? z<`711!Wlq={z;@~Sj8yJEP-hvjQAk-;|8!V3FTwtd!?{JB)l;OlWLO+&XHAJ>->^H z;@N&WhIO>e&pF`K0&oqCN&|BwjZTAdoUm<RqH}b}BX*IQpB)ru=pf_@w<<_w3``>l zbqsQ0;!`5DodZgJK&sRQc9y7#U@cC5i)*Y&0v0aG<7Oi{iz`z62t{fU_=>gV;SSyJ zVay)UP=KQrJ_#>itm>mP;GTjl%Q@d-9)7XwCP3Bj8ufJrAL&kWZMuI94d8#^#S5Jp z{|ncW?SJE1a<DP7@$%w3Iyo5YTSK{JUH{dHC2qI(xz$ye1yj!KlF%d2D>W5+4uo0L z>EqLLqQ1hVA(r~twZD5!)3~b0D8Ek5i=X4th)*XzgaZqlYIGL)cHKDp>E#@py_#-M zQ@lSvEG^~ZOV66wH;PXFv3b6Vh$d?__Kac3`M&=s{rOH^%smruWn~q2^eCqLp76kX z`)hI46`Un^T5a|0#5!NP==*)*1oC|xE4TYOlKuVa`+NS7^L?-F{XVwE`|)^x-n8}k z__Vz(f71PV-~Ko)^^y$E`65Qk<m=`xZY)4R-<#oecbr)2sHepWS$Bkt(I;&)N(ks8 z;?BKa4>6OBJB9GJgyuPAyBH)1cDLl|KS+^sg4vh}zWLYbPGpe|_2{2LyJUN`Do2qj z2)v1Y;?=Ta6da6io65w~N>Wh7A1#rRL(R-<NIiJZfpIElQn;G^ub*np{qoEihzzKN z@b(WR(vuNPbE2W7sQ>(LGb92;Hk4D2M*$ebrh&{j)V5$9*+DaWj5&Xu_}TGT-GPIh z7O1Fbj1HOLAMfl*DAKHRZ!zRYEmjn>^4-LL!j}e0(on>t5I{L?2i_Y60pYqgMnH|R z<oIKXryhy8u>!heK8wd3YkE}pU<R!5WCC0;(Ds|o7{K*p!C87vFwhj3z-B{j*{$lM za|kAScMOEq2F#`h`cV8rA?~+AIc3vxS<#9ujHC%3YgJy=Fh=q)45Q(z3>^<9jnVk1 zFpl{~$ao>cddQZj%(j=C#M8WN@ywq^d|2<tnP-|r^lB}PM%-hY-`Hc6>hfSABq(Jy z%rKKV%8<>G{==tf{LrAP7ON^+hA<UVpOW&RzU~8LJdInHY}F#QUb!95123$1tm1TD za%93IOuJN7`8a8}U)Q2}eNpg|_mbj_=tFLd;QS6D?j3(6bkYasve>*k8S6nzx^ma1 z={uBN*xK;}=DEhR`MuCj1Zm05U?TOYROTwg8Jpbp)zJPbatu&`$Jv9mml*xUK*tOd zAd|-oAxQvGrVBAnz;o}UA`}Wo`!rfr@vl!tRiX7Cw<^%-=FUHPA)6O{B+cZ3EPN>f zEC1!3%{d8DEi(v4f{g|KyHo}uS}zZOMuv!OX?=jGb}}W^bkgHO|Dna-BksWW=K_oe z9$gIngQNV%z|Kf$WB!n(n}4?K-Ecmzq5#9)y-ia=LQ%<ST*kTb@cZ@ga1k%(JMjDG zlnj=3Vx;92`KUKXnhNrkvt!N`NTL{>9Ew(ww7l=f!Ok8aX7Au*mzU3aNK_~u${qXW z5?)Rk$8q4tfeIYI+2HlB93X^loVSm=0SAFY9r9&2+R(fW=TfUEQ^)<AxF{Y~iUWyR zLiS}?%g3LRcrT^VMdaS>!_B-2a-@>@E_ESiTqF%N+z5$Hmj(@wZc}cyMA*^6AVPxj ziavynt*m4A$Pm3Qg|x=t><UCzJnlvI93{!&FqHz6grPB*mXxXbLsN;+aj!r0S$~4> zhF~#k#xt2Qj~ooqr1g4o)kV9zL@LbKY{Fsu0m{FmY2*F2Dw~0h>eX8;SH>W+S^EZM zbHs_u#sJRH79;6+c|luVR2YFi0+A9^cR#5}!;!?eq>ZSan_tX}Q81kyW)5D*w&JwI zwb0Y(=}n0E`!c9k*R5zx@eu~QXabx{iGj!QuFG+Uc0<Ai2sDrKzl}&A4!!<@Xq85< zv_|e>m&63Ur?i*iEDJz>WlM1`aC#6nMQ@6dqS2G!>yqG@mqOM1?@+Sl`uTt)Jp(Un zXktV%Ry79aaa-KS^>F}2d?F1@Xoia?9~z+WBabxVGZ(5L(4zzV`p!{_U-v-~V^aW3 z(3gQ)Fr7hBhHwK|UtI|{Uwd6!MDt#Sm@{gmUz}H5zbX5MUJZ|QjoH$D1xxR6(O;w# zpg}!_M-q@U<YU`v{MPY=!j<);G?0^`v>2Dv#6t#f<qWLNKY8O{$m;X=Jr6ZZCpomF z2;9PcnN;JX`^5990XC>+m{Gza#MfKfq>Oa&M9pU5gfl0E3+Cam(qNZn1@b_ajH8Lj zB1(KQfn*omnevxNn93K89U)*I`Us(FnK*PBP}or}Ynd&`jcJ4%0D*PL8i-n_`yu@G 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Ф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 X@9J6Dxzul;ca4>*T)9q@x~2XvG3D3A diff --git a/plugins/User/config/user.php b/plugins/User/config/user.php deleted file mode 100644 index 1f77bd2..0000000 --- a/plugins/User/config/user.php +++ /dev/null @@ -1,17 +0,0 @@ -<?php - -declare(strict_types=1); - -/** - * User plugin configuration. - * - * Published to the project's config/user.php when the plugin is enabled - * (hkm plugins enable User). Read values through your config loader / - * the env() helper. Keep secrets in .env, not here. - */ -return [ - 'enabled' => true, - - // Add user settings here. - // 'cache_ttl' => (int) (env('USER_CACHE_TTL') ?: 3600), -]; diff --git a/plugins/User/database/factories/UserFactory.php b/plugins/User/database/factories/UserFactory.php deleted file mode 100644 index 66f9ea0..0000000 --- a/plugins/User/database/factories/UserFactory.php +++ /dev/null @@ -1,33 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Seeder\Factory\EntityFactory; -use AlfaCode\LetMigrate\Seeder\Factory\FakeData; -use Plugins\Crypto\Infrastructure\PasswordHasher; - -/** - * UserFactory — generate fake `user` rows for seeders / tests. - * - * Published to database/factories/ on enable. - */ -// Depend on the crypto.services plugin for password hashing. -$hasher = new PasswordHasher(); - -return EntityFactory::for('users') - ->definition(function (FakeData $f, int $i) use ($hasher) { - $now = date('Y-m-d H:i:s'); - - return [ - 'user_id' => substr(str_replace('-', '', $f->uuid()), 0, 31), - 'username' => 'user' . ($i + 1), - 'email' => $f->uniqueEmail($i), - 'password_hash' => $hasher->make('password'), - // Verified email = the login gate; set it so factory users can log in. - 'email_verified_at' => $now, - 'created_at' => $now, - 'updated_at' => $now, - ]; - }) - ->locale('en_US') - ->count(10); diff --git a/plugins/User/database/migrations/2026_01_01_000000_create_user_table.php b/plugins/User/database/migrations/2026_01_01_000000_create_user_table.php deleted file mode 100644 index 2407fda..0000000 --- a/plugins/User/database/migrations/2026_01_01_000000_create_user_table.php +++ /dev/null @@ -1,138 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * User — create the `users` table. - * - * Published to database/migrations/ on `hkm plugins enable User` and run - * by `migrate:run`. On `hkm plugins disable User` (with unpublish) the - * down() below is rolled back before the file is removed. Always write a - * matching down() — the unpublish rollback depends on it. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->create('users', static function ($t) { - // --- Primary key ------------------------------------------------ - // WHAT: Auto-increment BIGINT surrogate key (`id`). - // WHY: Internal numeric PK keeps foreign keys, joins and indexes - // compact and fast; it is NEVER exposed to the outside world. - // WHERE: Used internally by the repository for row identity and FK - // references from other central tables (e.g. user_tenants). - // External callers always use `user_id` instead. - $t->id(); - - // --- Public identifier ------------------------------------------ - // WHAT: ULID string — the user's public, URL-safe identifier. - // WHY: ULIDs are sortable, collision-resistant and leak no row - // count (unlike the auto-increment id). Exposing the numeric - // PK would let outsiders guess record counts / enumerate. - // WHERE: This is the value carried in JWT `sub` and Identity.userId; - // every API URL and cross-module reference uses it. - $t->char('user_id', 31)->comment('ULID public identifier'); - - // --- Login / display name --------------------------------------- - // WHAT: The chosen username (max 50 chars). - // WHY: Human-friendly handle for login + display. Capped at 50 to - // bound index size and keep the unique index lean. - // WHERE: Read on registration and credential verification in - // UserService; shown in admin/user listings. - $t->string('username', 50); - - // --- Email ------------------------------------------------------ - // WHAT: The user's email address (max 150 chars). - // WHY: Primary contact + alternate login + recovery channel. 150 - // comfortably fits real-world addresses while staying - // indexable. - // WHERE: Used for verification emails, password reset, and as a - // unique login key in UserService. - $t->string('email', 150); - - // --- Password hash ---------------------------------------------- - // WHAT: bcrypt hash of the password — ALWAYS exactly 60 chars. - // WHY: Never store plaintext. CHAR(60) matches bcrypt's fixed - // output exactly (no wasted space, no truncation risk). - // Nullable-free: federated users still get a row, see below. - // WHERE: Written by UserService on register/change-password; - // compared with a timing-safe verify + rehash-on-login. - $t->char('password_hash', 60)->comment('bcrypt — always 60 chars'); - - // --- Remember-me token ------------------------------------------ - // WHAT: SHA-256 hash (64 hex chars) of the "remember me" cookie - // token. Nullable — only set when the user opts in. - // WHY: We store the HASH, never the raw token, so a DB leak can't - // be used to forge long-lived sessions. SHA-256 → 64 chars. - // WHERE: Validated on auto-login from the persistent cookie; see - // idx_remember_token below for the lookup index. - $t->char('remember_token', 64)->nullable() - ->comment('SHA-256 of the actual token for "remember me" cookie'); - - // --- Optimistic-lock version ------------------------------------ - // WHAT: Monotonic version counter, starts at 1. - // WHY: Implements optimistic concurrency: an UPDATE includes the - // expected version; a mismatch means someone else wrote - // first → OptimisticLockException, no lost update. - // WHERE: Bumped on every UserService update; checked in the WHERE - // clause of the update statement. - $t->unsignedInteger('version')->default(1) - ->comment('Optimistic-lock version — bumped on every update'); - - // --- Email verified timestamp ----------------------------------- - // WHAT: When the user confirmed ownership of their email. - // Nullable = not yet verified. - // WHY: Stores the proof-of-verification moment rather than a bare - // boolean, which doubles as an audit trail. - // WHERE: Set when the emailed confirmation link is consumed; gates - // features that require a verified address. - $t->timestamp('email_verified_at')->nullable() - ->comment('Set when the user confirms their email'); - - // --- Row timestamps --------------------------------------------- - // WHAT: created_at — set once at INSERT; updated_at — set at INSERT - // and auto-refreshed on every UPDATE. - // WHY: Standard auditing of when a row was created and last - // changed. DB-side defaults guarantee they are always - // populated even on raw writes. (On PostgreSQL LetMigrate - // emits a BEFORE UPDATE trigger for onUpdateCurrentTimestamp.) - // WHERE: Read for sorting/auditing; never set by hand in app code. - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP') - ->onUpdateCurrentTimestamp(); - - // --- Soft delete ------------------------------------------------ - // WHAT: Adds a nullable `deleted_at` column. - // WHY: Soft delete preserves identity history and FK integrity - // instead of hard-removing the row. A NULL means "live". - // WHERE: Every repository query filters `deleted_at IS NULL`. - $t->softDeletes(); - - // --- Constraints & indexes -------------------------------------- - // uniq_user_id: the public ULID must be globally unique (it is the - // external identity) and this index also speeds id→row lookups. - $t->unique(['user_id'], 'uniq_user_id'); - // Identity is global: one human = one account. - // uniq_username / uniq_email: enforce one account per handle/email - // platform-wide (no per-tenant duplication) and back fast login - // lookups by username or email. - $t->unique(['username'], 'uniq_username'); - $t->unique(['email'], 'uniq_email'); - // idx_remember_token: indexes the hashed cookie token so auto-login - // resolves the user in one indexed lookup. - $t->index(['remember_token'], 'idx_remember_token'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - $t->rowFormat('DYNAMIC'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('users'); - } -}; diff --git a/plugins/User/database/migrations/2026_01_01_000001_create_user_outbox_table.php b/plugins/User/database/migrations/2026_01_01_000001_create_user_outbox_table.php deleted file mode 100644 index 8675b9b..0000000 --- a/plugins/User/database/migrations/2026_01_01_000001_create_user_outbox_table.php +++ /dev/null @@ -1,120 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * Transactional outbox for the User domain. - * - * Integration events are written into this table INSIDE the same transaction - * that mutates the user, then relayed to the EventBus by `user:outbox:relay`. - * This guarantees at-least-once delivery even if the process dies between - * commit and dispatch — the classic outbox pattern. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->create('user_outbox', static function ($t) { - // --- Primary key ------------------------------------------------ - // WHAT: Auto-increment BIGINT surrogate key. - // WHY: Cheap monotonic row identity; the relay updates a row by - // this `id` after dispatch (see OutboxRelay). - // WHERE: Internal only — never leaves the table. - $t->id(); - - // --- Event id --------------------------------------------------- - // WHAT: UUID identifying this specific event instance. - // WHY: The consumer-side idempotency key: at-least-once delivery - // means a message can arrive twice, so consumers dedupe on - // this value. CHAR(36) = canonical UUID string length. - // WHERE: Written by OutboxRepository; carried in the dispatched event so - // downstream handlers can skip duplicates. uniq_event_id below. - $t->char('event_id', 36)->comment('UUID — idempotency key for consumers'); - - // --- Event name ------------------------------------------------- - // WHAT: The integration event's logical name (e.g. user.registered). - // WHY: Lets the relay/consumers route by type without decoding the - // payload. 100 chars comfortably fits any dotted event name. - // WHERE: Set from IntegrationEventContract::name() by OutboxRepository. - $t->string('event_name', 100)->comment('e.g. user.registered'); - - // --- Event version ---------------------------------------------- - // WHAT: Schema version of the payload (default '1.0'). - // WHY: Payloads evolve; the version lets consumers handle old and - // new shapes side by side during a rollout. - // WHERE: Set from the event's version(); read by consumers. - $t->string('event_version', 16)->default('1.0'); - - // --- Payload ---------------------------------------------------- - // WHAT: The full event body as JSON. - // WHY: Self-contained message — everything a consumer needs travels - // with the row, so the relay never re-queries the domain. - // JSON column keeps it queryable and driver-portable. - // WHERE: Encoded from IntegrationEventContract::payload() on write. - $t->json('payload'); - - // --- Dispatch status -------------------------------------------- - // WHAT: Delivery state: 0=pending, 1=dispatched, 2=failed. - // WHY: Drives the relay loop — it claims pending rows, marks them - // dispatched on success, or parks them as failed after the - // max attempts. tinyint(unsigned) is the cheapest enum. - // WHERE: OutboxRelay SELECTs WHERE status = 0 and UPDATEs it; first - // column of idx_status_occurred for the pending scan. - $t->tinyInteger('status')->unsigned()->default(0) - ->comment('0=pending,1=dispatched,2=failed'); - - // --- Attempts --------------------------------------------------- - // WHAT: How many delivery attempts have been made. - // WHY: Powers retry budgeting — once it hits the relay's max, the - // row is parked as failed (status 2) instead of looping. - // WHERE: Incremented by OutboxRelay on each dispatch attempt. - $t->unsignedInteger('attempts')->default(0); - - // --- Last error ------------------------------------------------- - // WHAT: Truncated text of the most recent delivery failure. - // Nullable — empty until something fails. - // WHY: Diagnostics for stuck/failed rows without external logging. - // WHERE: Written by OutboxRelay when an attempt throws. - $t->text('last_error')->nullable(); - - // --- Occurred at ------------------------------------------------ - // WHAT: When the domain event actually happened. - // WHY: The ordering key so events relay in the order they occurred - // (oldest-first), preserving causal order for consumers. - // WHERE: Set on write; second column of idx_status_occurred. - $t->timestamp('occurred_at'); - - // --- Dispatched at ---------------------------------------------- - // WHAT: When the event was successfully relayed. Nullable until then. - // WHY: Marks completion and gives delivery-latency visibility. - // WHERE: Stamped by OutboxRelay on successful dispatch. - $t->timestamp('dispatched_at')->nullable(); - - // --- Created at ------------------------------------------------- - // WHAT: Row insertion time (DB default). - // WHY: Audit of when the outbox record was written, independent of - // occurred_at. Always populated even on raw inserts. - // WHERE: Read for auditing; never set by hand. - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - - // --- Constraints & indexes -------------------------------------- - // uniq_event_id: guarantees one row per event instance (no double - // write inside the producing transaction) and dedupes on replay. - $t->unique(['event_id'], 'uniq_event_id'); - // idx_status_occurred: composite index for the relay's hot query — - // "pending rows, oldest first" — so it never scans the whole table. - $t->index(['status', 'occurred_at'], 'idx_status_occurred'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('user_outbox'); - } -}; diff --git a/plugins/User/database/migrations/2026_01_01_000002_add_email_verification_token_to_users.php b/plugins/User/database/migrations/2026_01_01_000002_add_email_verification_token_to_users.php deleted file mode 100644 index c94e711..0000000 --- a/plugins/User/database/migrations/2026_01_01_000002_add_email_verification_token_to_users.php +++ /dev/null @@ -1,46 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * User — add stored-hash email-verification token columns to `users`. - * - * A public self-signup is NOT authenticated when it later clicks the emailed - * confirmation link, so email verification CANNOT be identity-gated. Instead we - * email a single random token and store only its SHA-256 hash here (never the - * raw token — a DB leak must not let an attacker confirm arbitrary accounts). - * The token is one-time (cleared on use) and time-boxed (expiry column). - * - * SCOPE: central `users` table (identity). Separate NEW migration so the base - * table migration is never mutated after it has been applied. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->table('users', static function ($t) { - // SHA-256 (64 hex chars) of the emailed verification token. NULL once - // the email is verified or before any token is issued. - $t->char('email_verification_token_hash', 64)->nullable() - ->comment('SHA-256 of the emailed verification token — never the raw token'); - - // Hard expiry for the pending token; a link past this is rejected. - $t->timestamp('email_verification_expires_at')->nullable() - ->comment('When the pending verification token stops being valid'); - - // Single-row lookup by token hash on the public verify endpoint. - $t->index(['email_verification_token_hash'], 'idx_email_verif_token'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->table('users', static function ($t) { - $t->dropIndex('idx_email_verif_token'); - $t->dropColumn('email_verification_token_hash'); - $t->dropColumn('email_verification_expires_at'); - }); - } -}; diff --git a/plugins/User/database/migrations/2026_07_19_000001_add_platform_admin_to_users.php b/plugins/User/database/migrations/2026_07_19_000001_add_platform_admin_to_users.php deleted file mode 100644 index 4251af9..0000000 --- a/plugins/User/database/migrations/2026_07_19_000001_add_platform_admin_to_users.php +++ /dev/null @@ -1,66 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * User — mark platform administrators on the CENTRAL `users` table. - * - * Owned by the User plugin because `users` is User's table: a column on it is - * User's to define, even though the privilege it records is consumed by the - * control plane. No other plugin or project should alter this table. - * - * WHY A COLUMN AND NOT A ROLE - * --------------------------- - * A platform administrator can enumerate every tenant and read data inside any - * of them. That is the single highest privilege on the platform, so the flag - * that grants it must live OUTSIDE the tenant model entirely — a tenant-scoped - * role or policy edit must never be able to reach it. - * - * Casbin (`casbin_rule`) stays the right tool for finer-grained permissions - * WITHIN the admin surface (who may delete a tenant vs. only view one). This - * column is the outer gate those finer rules sit behind. - * - * A platform admin is deliberately NOT a tenant: it holds no row in `tenants` - * and no membership in `user_tenants`. Making the control plane a tenant would - * put it inside the resource it administers — it could suspend or delete its - * own database, and a tenant-DB outage would take down the tool needed to - * diagnose that outage. - * - * SCOPE: central `users` only. Tenant databases never carry this column; a - * tenant DB is not a place where platform privilege can be granted. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->table('users', static function ($t) { - // Default 0: every existing and future account is a normal user - // until explicitly promoted. Privilege is never acquired by - // accident, only by a deliberate write. - $t->boolean('is_platform_admin')->default(0) - ->comment('1 = platform (super) administrator — full cross-tenant access'); - - // Partial-ish lookup: the admin list is a handful of rows in a table - // that may hold millions, so the index keeps "who are the admins?" - // cheap without scanning. - $t->index(['is_platform_admin'], 'idx_users_platform_admin'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - // Drop the COLUMN only — deliberately not the index. - // - // On MySQL, dropping a column automatically drops any single-column - // index over it. Compiling an explicit DROP INDEX alongside the column - // drop therefore fails with "Can't DROP INDEX … check that it exists": - // by the time that statement runs the index is already gone, and the - // rollback aborts HALF-DONE — column removed, migration still recorded - // as applied, schema and tracking table out of sync. - $schema->table('users', static function ($t) { - $t->dropColumn('is_platform_admin'); - }); - } -}; diff --git a/plugins/User/database/seeders/UserSeeder.php b/plugins/User/database/seeders/UserSeeder.php deleted file mode 100644 index c718666..0000000 --- a/plugins/User/database/seeders/UserSeeder.php +++ /dev/null @@ -1,65 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\DatabaseDriverInterface; -use AlfaCode\LetMigrate\Seeder\SeederInterface; -use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\HashingPort; -use Plugins\Crypto\Infrastructure\PasswordHasher; - -/** - * UserSeeder — populate the `user` table with baseline data. - * - * Published to database/seeders/ on enable; run with `db:seed`. Uses the raw - * driver (execute/insert/fetchAll) — no query builder, maximum speed. - */ -final class UserSeeder implements SeederInterface -{ - private readonly HashingPort $hasher; - - public function __construct(?HashingPort $hasher = null) - { - // Depend on the crypto.services plugin for password hashing. - $this->hasher = $hasher ?? new PasswordHasher(); - } - - public function run(DatabaseDriverInterface $db): void - { - $now = date('Y-m-d H:i:s'); - - $db->insert('users', [ - 'user_id' => $this->ulid(), - 'username' => 'admin', - 'email' => 'admin@example.com', - 'password_hash' => $this->hasher->make('password'), - // Verified email = the login gate; set it so the admin can log in. - 'email_verified_at' => $now, - 'created_at' => $now, - 'updated_at' => $now, - ]); - } - - /** Generate a 26-char Crockford ULID (fits the char(31) user_id column). */ - private function ulid(): string - { - $alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; - $time = (int) (microtime(true) * 1000); - - $ulid = ''; - for ($i = 9; $i >= 0; $i--) { - $ulid = $alphabet[$time % 32] . $ulid; - $time = intdiv($time, 32); - } - for ($i = 0; $i < 16; $i++) { - $ulid .= $alphabet[random_int(0, 31)]; - } - - return $ulid; - } - - /** @return string[] */ - public function getDependencies(): array - { - return []; - } -} diff --git a/plugins/User/database/tenant-template/2026_06_29_000001_create_user_profiles_table.php b/plugins/User/database/tenant-template/2026_06_29_000001_create_user_profiles_table.php deleted file mode 100644 index 901ddf5..0000000 --- a/plugins/User/database/tenant-template/2026_06_29_000001_create_user_profiles_table.php +++ /dev/null @@ -1,56 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * User — create the TENANT-scoped `user_profiles` table. - * - * SCOPE: this lives in the User plugin's tenant-template, so it is applied to - * each TENANT database by `tenant:migrate` (NOT the central DB). Identity itself - * (`users`) is central; this is per-tenant presentation data for that identity. - * - * `user_id` is a SOFT reference to the central `users.id` (bigint unsigned). It - * carries NO foreign key: the referenced row lives in a different (central) - * database, so a cross-DB FK is impossible — integrity is enforced in the - * service layer, never by the engine. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->create('user_profiles', static function ($t) { - // One profile per user per tenant → user_id IS the primary key. - // char(31) matches the central users.user_id ULID width. - $t->char('user_id', 31) - ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); - - $t->string('first_name', 80)->nullable(); - $t->string('last_name', 80)->nullable(); - $t->string('avatar_url', 500)->nullable() - ->comment('Absolute or storage-relative avatar URL'); - $t->string('timezone', 50)->default('UTC') - ->comment('IANA tz name, e.g. Africa/Kampala'); - $t->char('locale', 5)->default('en_US') - ->comment('ll_CC language tag'); - $t->string('phone', 15)->default('0700000000') - ->comment('E.164-ish local number; default is a placeholder'); - - // Touched on every write so callers can cache-bust on profile edits. - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP') - ->onUpdateCurrentTimestamp(); - - $t->primary(['user_id'], 'pk_user_profiles'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('user_profiles'); - } -}; diff --git a/plugins/User/database/tenant-template/2026_06_29_000002_create_user_privacy_settings_table.php b/plugins/User/database/tenant-template/2026_06_29_000002_create_user_privacy_settings_table.php deleted file mode 100644 index e54a104..0000000 --- a/plugins/User/database/tenant-template/2026_06_29_000002_create_user_privacy_settings_table.php +++ /dev/null @@ -1,50 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * User — create the TENANT-scoped `user_privacy_settings` table. - * - * One row per user per tenant (uniq_user_privacy). Applied per-tenant DB by - * `tenant:migrate`. `user_id` is a soft reference to central `users.id` — no - * cross-DB foreign key (see user_profiles migration for the rationale). - * - * Booleans are stored as tinyint(1); the Blueprint `boolean()` compiles to the - * correct per-driver type. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->create('user_privacy_settings', static function ($t) { - $t->id(); - - $t->char('user_id', 31) - ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); - - $t->string('profile_visibility', 10)->default('public') - ->comment('public|private|contacts'); - $t->boolean('show_phone')->default(true); - $t->boolean('show_email')->default(false); - $t->boolean('marketing_opt_in')->default(false); - $t->boolean('analytics_opt_in')->default(true); - - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP') - ->onUpdateCurrentTimestamp(); - - // One settings row per user — also the lookup index. - $t->unique(['user_id'], 'uniq_user_privacy'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('user_privacy_settings'); - } -}; diff --git a/plugins/User/database/tenant-template/2026_06_29_000003_create_user_preferences_table.php b/plugins/User/database/tenant-template/2026_06_29_000003_create_user_preferences_table.php deleted file mode 100644 index f106189..0000000 --- a/plugins/User/database/tenant-template/2026_06_29_000003_create_user_preferences_table.php +++ /dev/null @@ -1,54 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * User — create the TENANT-scoped `user_preferences` table. - * - * One row per user per tenant (uniq_user_preferences). Applied per-tenant DB by - * `tenant:migrate`. `user_id` is a soft reference to central `users.id` — no - * cross-DB foreign key. - * - * Holds locale/currency/theme plus accessibility toggles. Booleans are - * tinyint(1) via `boolean()`. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->create('user_preferences', static function ($t) { - $t->id(); - - $t->char('user_id', 31) - ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); - - $t->string('language', 10)->default('en'); - $t->string('currency', 10)->default('UGX') - ->comment('ISO 4217 display currency'); - $t->string('theme', 10)->default('system') - ->comment('light|dark|system'); - - // Accessibility toggles. - $t->boolean('reduce_motion')->default(false); - $t->boolean('larger_text')->default(false); - $t->boolean('high_contrast')->default(false); - $t->boolean('screen_reader_hints')->default(false); - - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP') - ->onUpdateCurrentTimestamp(); - - $t->unique(['user_id'], 'uniq_user_preferences'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('user_preferences'); - } -}; diff --git a/plugins/User/database/tenant-template/2026_06_29_000004_create_user_notification_preferences_table.php b/plugins/User/database/tenant-template/2026_06_29_000004_create_user_notification_preferences_table.php deleted file mode 100644 index 647c052..0000000 --- a/plugins/User/database/tenant-template/2026_06_29_000004_create_user_notification_preferences_table.php +++ /dev/null @@ -1,68 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * User — create the TENANT-scoped `user_notification_preferences` table. - * - * One row per user per tenant (uniq_user_notif_prefs). Applied per-tenant DB by - * `tenant:migrate`. `user_id` is a soft reference to central `users.id` — no - * cross-DB foreign key. - * - * A flat opt-in matrix of (channel × topic). Channels: push / email / sms. - * Topics: messages, bookings, payments, reminders, promotions, security. - * Defaults follow least-surprise: transactional topics on, promotions off, - * security always on by default. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->create('user_notification_preferences', static function ($t) { - $t->id(); - - $t->char('user_id', 31) - ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); - - // Push channel. - $t->boolean('push_messages')->default(true); - $t->boolean('push_bookings')->default(true); - $t->boolean('push_payments')->default(true); - $t->boolean('push_reminders')->default(true); - $t->boolean('push_promotions')->default(false); - $t->boolean('push_security')->default(true); - - // Email channel. - $t->boolean('email_messages')->default(false); - $t->boolean('email_bookings')->default(true); - $t->boolean('email_payments')->default(true); - $t->boolean('email_reminders')->default(false); - $t->boolean('email_promotions')->default(false); - $t->boolean('email_security')->default(true); - - // SMS channel. - $t->boolean('sms_messages')->default(false); - $t->boolean('sms_bookings')->default(true); - $t->boolean('sms_payments')->default(true); - $t->boolean('sms_reminders')->default(false); - $t->boolean('sms_promotions')->default(false); - $t->boolean('sms_security')->default(true); - - $t->timestamp('updated_at')->default('CURRENT_TIMESTAMP') - ->onUpdateCurrentTimestamp(); - - $t->unique(['user_id'], 'uniq_user_notif_prefs'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('user_notification_preferences'); - } -}; diff --git a/plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php b/plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php deleted file mode 100644 index 73e3b9f..0000000 --- a/plugins/User/database/tenant-template/2026_06_29_000005_create_user_feedback_table.php +++ /dev/null @@ -1,55 +0,0 @@ -<?php - -declare(strict_types=1); - -use AlfaCode\LetMigrate\Contract\MigrationInterface; -use AlfaCode\LetMigrate\Contract\SchemaBuilderInterface; - -/** - * User — create the TENANT-scoped `user_feedback` table. - * - * Applied per-tenant DB by `tenant:migrate`. `user_id` is a soft reference to - * central `users.id` — no cross-DB foreign key. - * - * Captures in-app feedback submissions. `feedback_id` is the PUBLIC opaque id - * handed back to the client (never the auto-increment `id`), so we don't leak - * row counts. Many feedback rows per user → user_id is a non-unique index. - */ -return new class implements MigrationInterface { - public function up(SchemaBuilderInterface $schema): void - { - $schema->create('user_feedback', static function ($t) { - $t->id(); - - $t->char('user_id', 31) - ->comment('Soft ref to central users.user_id (ULID) — no cross-DB FK'); - - $t->char('feedback_id', 36) - ->comment('Public opaque ID (UUID) returned to the client'); - $t->string('category', 60)->nullable() - ->comment('search_browsing|messaging|payments|hosting|app_performance|feature_request|other'); - $t->unsignedTinyInteger('rating')->nullable() - ->comment('1-5 star rating'); - $t->text('message'); - $t->string('status', 20)->default('received') - ->comment('received|acknowledged|resolved'); - - $t->timestamp('created_at')->default('CURRENT_TIMESTAMP'); - - // Public id is globally unique + the client-facing lookup key. - $t->unique(['feedback_id'], 'uniq_feedback_id'); - // List a user's submissions; triage by status. - $t->index(['user_id'], 'idx_feedback_user'); - $t->index(['status'], 'idx_feedback_status'); - - $t->engine('InnoDB'); - $t->charset('utf8mb4'); - $t->collation('utf8mb4_0900_ai_ci'); - }); - } - - public function down(SchemaBuilderInterface $schema): void - { - $schema->dropIfExists('user_feedback'); - } -}; diff --git a/plugins/User/module.json b/plugins/User/module.json deleted file mode 100644 index 4f09014..0000000 --- a/plugins/User/module.json +++ /dev/null @@ -1,286 +0,0 @@ -{ - "name": "user", - "version": "1.0.0", - "solves": "user.management", - "type": "module", - "requires": [ - "database.management", - "crypto.services", - "cache.redis", - "view.rendering", - "http.client", - "validation.rules", - "mail.delivery", - "feedback.management", - "audit.trail" - ], - "exposes": [ - "Plugins\\User\\API\\Contracts\\UserServiceContract" - ], - "views": "resources/views", - "routes": [ - { - "method": "GET", - "path": "/admin/users", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@adminIndex", - "requires": [ - "http.pageflow" - ], - "filters": [ - "auth" - ] - }, - { - "method": "GET", - "path": "/admin/users/{id}", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@adminShow", - "requires": [ - "http.pageflow" - ], - "filters": [ - "auth" - ] - }, - { - "method": "GET", - "path": "/register", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@register", - "requires": [ - "http.pageflow" - ] - }, - { - "method": "GET", - "path": "/account/profile", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@profile", - "requires": [ - "http.pageflow" - ], - "filters": [ - "auth" - ] - }, - { - "method": "GET", - "path": "/users", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@index" - }, - { - "method": "GET", - "path": "/users/create", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@create" - }, - { - "method": "GET", - "path": "/users/{id}/edit", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@edit" - }, - { - "method": "GET", - "path": "/users/{id}", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@show" - }, - { - "method": "GET", - "path": "/verify-email", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserFlowController@verifyEmail", - "requires": [ - "http.pageflow" - ] - }, - { - "method": "GET", - "path": "/users/verify", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@verify" - }, - { - "method": "GET", - "path": "/account/settings", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserPageController@settings" - }, - { - "method": "GET", - "path": "/ajx/users", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@index", - "filters": [ - "auth" - ] - }, - { - "method": "POST", - "path": "/ajx/users", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@register", - "filters": [ - "throttle:10,1" - ] - }, - { - "method": "POST", - "path": "/ajx/admin/users", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@adminCreate", - "filters": [ - "auth", - "throttle:30,1" - ] - }, - { - "method": "POST", - "path": "/ajx/users/verify", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@verifyEmailByToken", - "filters": [ - "throttle:10,1" - ] - }, - { - "method": "POST", - "path": "/ajx/users/resend-verification", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@resendVerification", - "filters": [ - "throttle:5,10" - ] - }, - { - "method": "GET", - "path": "/ajx/users/{id}", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@show", - "filters": [ - "auth" - ] - }, - { - "method": "POST", - "path": "/ajx/users/{id}/verify-email", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@verifyEmail", - "filters": [ - "auth" - ] - }, - { - "method": "PUT", - "path": "/ajx/users/{id}", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@update", - "filters": [ - "auth" - ] - }, - { - "method": "PATCH", - "path": "/ajx/users/{id}", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@update", - "filters": [ - "auth" - ] - }, - { - "method": "DELETE", - "path": "/ajx/users/{id}", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserController@destroy", - "filters": [ - "auth" - ] - }, - { - "method": "GET", - "path": "/ajx/profile", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showProfile", - "filters": [ - "auth", - "tenant" - ] - }, - { - "method": "PUT", - "path": "/ajx/profile", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updateProfile", - "filters": [ - "auth", - "tenant", - "throttle:30,1" - ] - }, - { - "method": "GET", - "path": "/ajx/preferences", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showPreferences", - "filters": [ - "auth", - "tenant" - ] - }, - { - "method": "PUT", - "path": "/ajx/preferences", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updatePreferences", - "filters": [ - "auth", - "tenant", - "throttle:30,1" - ] - }, - { - "method": "GET", - "path": "/ajx/privacy", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showPrivacy", - "filters": [ - "auth", - "tenant" - ] - }, - { - "method": "PUT", - "path": "/ajx/privacy", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updatePrivacy", - "filters": [ - "auth", - "tenant", - "throttle:30,1" - ] - }, - { - "method": "GET", - "path": "/ajx/notification-preferences", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@showNotifications", - "filters": [ - "auth", - "tenant" - ] - }, - { - "method": "PUT", - "path": "/ajx/notification-preferences", - "handler": "Plugins\\User\\Infrastructure\\Http\\Controllers\\UserSettingsController@updateNotifications", - "filters": [ - "auth", - "tenant", - "throttle:30,1" - ] - } - ], - "emits": [ - "user.registered", - "user.updated", - "user.deleted" - ], - "listens": [], - "documentation": "The User plugin \u2014 owns the user.management domain. CRUD + email verification + timing-safe, rate-limited credential verification over the GLOBAL central `users` identity table (identity is centralized, username/email globally unique, repository + outbox pinned to the central connection). Passwords are hashed via crypto.services (bcrypt, rehash-on-login); hashes and remember tokens never cross the API boundary. Writes are optimistic-locked (version); integration events use a transactional outbox drained by `user:outbox:relay`. Enabling publishes config/, database/ (migrations, seeder, factory) and resources/.", - "commands": [ - "Plugins\\User\\Infrastructure\\Cli\\RelayUserOutboxCommand" - ], - "config": [ - { - "key": "HASH_BCRYPT_COST", - "type": "int", - "required": false - }, - { - "key": "USER_BREACH_CHECK", - "type": "bool", - "required": false - }, - { - "key": "USER_BREACH_THRESHOLD", - "type": "int", - "required": false - } - ] -} diff --git a/plugins/User/resources/views/account/feedback.php b/plugins/User/resources/views/account/feedback.php deleted file mode 100644 index 61d328e..0000000 --- a/plugins/User/resources/views/account/feedback.php +++ /dev/null @@ -1,116 +0,0 @@ -<?php -/** - * Feedback demo (user::account/feedback). - * - * Demonstrates CRUD over the feedback resource: - * CREATE POST /ajx/feedback - * READ GET /ajx/feedback (admin triage list) - * READ GET /ajx/feedback/{id} (self or feedback:manage) - * UPDATE PATCH /ajx/feedback/{id} (feedback:manage — status) - * - * Same-site session cookie + CSRF header on unsafe requests. - */ -?> -<div class="card" style="margin-bottom:1.5rem"> - <h2>Submit feedback</h2> - <p class="muted">POST <code>/ajx/feedback</code></p> - <form id="create-form"> - <label>Category</label> - <select name="category"> - <option value="">(none)</option> - <option>search_browsing</option><option>messaging</option><option>payments</option> - <option>hosting</option><option>app_performance</option><option>feature_request</option><option>other</option> - </select> - <label>Rating (1–5, optional)</label><input name="rating" type="number" min="1" max="5"> - <label>Message</label><input name="message" placeholder="Tell us what you think"> - <div class="field-error" data-err="create"></div> - <div class="actions"><button class="btn btn-primary">Send feedback</button></div> - </form> -</div> - -<div class="card"> - <div style="display:flex;align-items:center;gap:1rem"> - <h2 style="margin:0">Triage <span class="muted">(admin)</span></h2> - <button class="btn btn-sm" id="reload" style="margin-left:auto">Reload</button> - </div> - <p class="muted">GET <code>/ajx/feedback</code> · PATCH <code>/ajx/feedback/{id}</code></p> - <table> - <thead><tr><th>ID</th><th>User</th><th>Category</th><th>Rating</th><th>Status</th><th></th></tr></thead> - <tbody id="rows"><tr><td colspan="6" class="muted">Loading…</td></tr></tbody> - </table> -</div> - -<script> -(function () { - const SAFE = { GET:1, HEAD:1, OPTIONS:1 }; - function csrf() { const m = document.querySelector('meta[name="csrf-token"]'); return m ? m.content : ''; } - async function api(method, path, body) { - const h = { 'Accept':'application/json', 'X-Requested-With':'XMLHttpRequest' }; - if (body !== undefined) h['Content-Type'] = 'application/json'; - if (!SAFE[method]) h['X-CSRF-Token'] = csrf(); - const res = await fetch(path, { method, headers:h, credentials:'same-origin', - body: body !== undefined ? JSON.stringify(body) : undefined }); - const t = await res.text(); const d = t ? JSON.parse(t) : null; - if (!res.ok) { const e = new Error((d&&d.error&&d.error.message)||('HTTP '+res.status)); e.status=res.status; e.fields=(d&&d.error&&d.error.fields)||{}; throw e; } - return d; - } - const flash = (m, t='ok') => window.UserApp && window.UserApp.flash(m, t); - const NEXT = { received:'acknowledged', acknowledged:'resolved' }; - - // CREATE - const form = document.getElementById('create-form'); - form.addEventListener('submit', async (ev) => { - ev.preventDefault(); - const payload = { - category: form.elements.category.value || null, - rating: form.elements.rating.value ? Number(form.elements.rating.value) : null, - message: form.elements.message.value, - }; - try { - await api('POST', '/ajx/feedback', payload); - form.reset(); flash('Feedback submitted'); load(); - } catch (e) { - form.querySelector('[data-err="create"]').textContent = Object.values(e.fields)[0] || e.message; - flash(e.message, 'error'); - } - }); - - // READ (list) + UPDATE (advance status) - const rows = document.getElementById('rows'); - async function load() { - rows.innerHTML = '<tr><td colspan="6" class="muted">Loading…</td></tr>'; - try { - const res = await api('GET', '/ajx/feedback'); - const items = res.data || []; - if (!items.length) { rows.innerHTML = '<tr><td colspan="6" class="muted">No feedback.</td></tr>'; return; } - rows.innerHTML = ''; - for (const f of items) { - const tr = document.createElement('tr'); - tr.innerHTML = - '<td class="muted">' + f.feedbackId.slice(0, 8) + '…</td>' + - '<td>' + f.userId + '</td>' + - '<td>' + (f.category || '—') + '</td>' + - '<td>' + (f.rating ?? '—') + '</td>' + - '<td><span class="badge' + (f.status === 'resolved' ? ' active' : '') + '">' + f.status + '</span></td>'; - const act = document.createElement('td'); - if (NEXT[f.status]) { - const b = document.createElement('button'); - b.className = 'btn btn-sm'; b.textContent = '→ ' + NEXT[f.status]; - b.addEventListener('click', () => advance(f.feedbackId, NEXT[f.status])); - act.appendChild(b); - } - tr.appendChild(act); rows.appendChild(tr); - } - } catch (e) { - rows.innerHTML = '<tr><td colspan="6" class="alert error">' + e.message + '</td></tr>'; - } - } - async function advance(id, status) { - try { await api('PATCH', '/ajx/feedback/' + encodeURIComponent(id), { status }); flash('Status → ' + status); load(); } - catch (e) { flash(e.message, 'error'); } - } - - document.getElementById('reload').addEventListener('click', load); - load(); -})(); -</script> diff --git a/plugins/User/resources/views/account/settings.php b/plugins/User/resources/views/account/settings.php deleted file mode 100644 index 24a32db..0000000 --- a/plugins/User/resources/views/account/settings.php +++ /dev/null @@ -1,148 +0,0 @@ -<?php -/** - * Account settings demo (user::account/settings). - * - * Demonstrates READ + UPDATE CRUD for the four self-scoped settings resources: - * GET/PUT /ajx/profile, /ajx/preferences, /ajx/privacy, - * /ajx/notification-preferences - * - * Same-site: the browser sends the session cookie; unsafe requests carry the - * CSRF token from the <meta> tag (added by the layout) in X-CSRF-Token. - */ -?> -<div class="card" style="margin-bottom:1.5rem"> - <h2>Profile</h2> - <p class="muted">GET / PUT <code>/ajx/profile</code></p> - <form id="profile-form"> - <label>First name</label><input name="firstName"> - <label>Last name</label><input name="lastName"> - <label>Avatar URL (http/https)</label><input name="avatarUrl"> - <label>Timezone</label><input name="timezone" placeholder="Africa/Kampala"> - <label>Locale</label><input name="locale" placeholder="en_US"> - <label>Phone</label><input name="phone"> - <div class="field-error" data-err="profile"></div> - <div class="actions"><button class="btn btn-primary">Save profile</button></div> - </form> -</div> - -<div class="card" style="margin-bottom:1.5rem"> - <h2>Preferences</h2> - <p class="muted">GET / PUT <code>/ajx/preferences</code></p> - <form id="preferences-form"> - <label>Language</label><input name="language" placeholder="en"> - <label>Currency</label><input name="currency" placeholder="UGX"> - <label>Theme</label> - <select name="theme"><option>system</option><option>light</option><option>dark</option></select> - <label><input type="checkbox" name="reduceMotion"> Reduce motion</label> - <label><input type="checkbox" name="largerText"> Larger text</label> - <label><input type="checkbox" name="highContrast"> High contrast</label> - <label><input type="checkbox" name="screenReaderHints"> Screen-reader hints</label> - <div class="field-error" data-err="preferences"></div> - <div class="actions"><button class="btn btn-primary">Save preferences</button></div> - </form> -</div> - -<div class="card" style="margin-bottom:1.5rem"> - <h2>Privacy</h2> - <p class="muted">GET / PUT <code>/ajx/privacy</code></p> - <form id="privacy-form"> - <label>Profile visibility</label> - <select name="profileVisibility"><option>public</option><option>private</option><option>contacts</option></select> - <label><input type="checkbox" name="showPhone"> Show phone</label> - <label><input type="checkbox" name="showEmail"> Show email</label> - <label><input type="checkbox" name="marketingOptIn"> Marketing opt-in</label> - <label><input type="checkbox" name="analyticsOptIn"> Analytics opt-in</label> - <div class="field-error" data-err="privacy"></div> - <div class="actions"><button class="btn btn-primary">Save privacy</button></div> - </form> -</div> - -<div class="card"> - <h2>Notifications</h2> - <p class="muted">GET / PUT <code>/ajx/notification-preferences</code></p> - <table id="notif-table"><tbody><tr><td class="muted">Loading…</td></tr></tbody></table> - <div class="actions"><button class="btn btn-primary" id="notif-save">Save notifications</button></div> -</div> - -<script> -(function () { - const SAFE = { GET:1, HEAD:1, OPTIONS:1 }; - function csrf() { const m = document.querySelector('meta[name="csrf-token"]'); return m ? m.content : ''; } - async function api(method, path, body) { - const h = { 'Accept':'application/json', 'X-Requested-With':'XMLHttpRequest' }; - if (body !== undefined) h['Content-Type'] = 'application/json'; - if (!SAFE[method]) h['X-CSRF-Token'] = csrf(); - const res = await fetch(path, { method, headers:h, credentials:'same-origin', - body: body !== undefined ? JSON.stringify(body) : undefined }); - const t = await res.text(); const d = t ? JSON.parse(t) : null; - if (!res.ok) { const e = new Error((d&&d.error&&d.error.message)||('HTTP '+res.status)); e.fields=(d&&d.error&&d.error.fields)||{}; throw e; } - return d; - } - function flash(msg, type='ok') { window.UserApp && window.UserApp.flash(msg, type); } - const val = (form, name) => form.elements[name]; - - function fillForm(form, data, errKey) { - for (const el of form.elements) { - if (!el.name) continue; - if (el.type === 'checkbox') el.checked = !!data[el.name]; - else if (data[el.name] != null) el.value = data[el.name]; - } - const err = form.querySelector('[data-err="'+errKey+'"]'); if (err) err.textContent = ''; - } - function collect(form) { - const out = {}; - for (const el of form.elements) { - if (!el.name) continue; - out[el.name] = el.type === 'checkbox' ? el.checked : el.value; - } - return out; - } - function wire(formId, path, errKey, label) { - const form = document.getElementById(formId); - api('GET', path).then(d => fillForm(form, d, errKey)).catch(e => flash(e.message, 'error')); - form.addEventListener('submit', async (ev) => { - ev.preventDefault(); - try { const d = await api('PUT', path, collect(form)); fillForm(form, d, errKey); flash(label + ' saved'); } - catch (e) { - const box = form.querySelector('[data-err="'+errKey+'"]'); - if (box) box.textContent = Object.values(e.fields)[0] || e.message; - flash(e.message, 'error'); - } - }); - } - - wire('profile-form', '/ajx/profile', 'profile', 'Profile'); - wire('preferences-form', '/ajx/preferences', 'preferences', 'Preferences'); - wire('privacy-form', '/ajx/privacy', 'privacy', 'Privacy'); - - // Notifications: a dynamic channel × topic checkbox matrix built from the - // nested { flags: { channel: { topic: bool } } } response. - const NOTIF = '/ajx/notification-preferences'; - const notifBody = document.querySelector('#notif-table tbody'); - function renderNotif(flags) { - notifBody.innerHTML = ''; - for (const channel of Object.keys(flags)) { - const tr = document.createElement('tr'); - const th = document.createElement('th'); th.textContent = channel; tr.appendChild(th); - for (const topic of Object.keys(flags[channel])) { - const td = document.createElement('td'); - const cb = document.createElement('input'); - cb.type = 'checkbox'; cb.checked = !!flags[channel][topic]; - cb.dataset.channel = channel; cb.dataset.topic = topic; - const lab = document.createElement('label'); lab.style.margin = '0'; - lab.append(cb, ' ' + topic); td.appendChild(lab); tr.appendChild(td); - } - notifBody.appendChild(tr); - } - } - api('GET', NOTIF).then(d => renderNotif(d.flags)).catch(e => flash(e.message, 'error')); - document.getElementById('notif-save').addEventListener('click', async () => { - const flags = {}; - notifBody.querySelectorAll('input[type=checkbox]').forEach(cb => { - (flags[cb.dataset.channel] ||= {})[cb.dataset.topic] = cb.checked; - }); - try { const d = await api('PUT', NOTIF, { flags }); renderNotif(d.flags); flash('Notifications saved'); } - catch (e) { flash(e.message, 'error'); } - }); -})(); -</script> diff --git a/plugins/User/resources/views/account/verify.php b/plugins/User/resources/views/account/verify.php deleted file mode 100644 index 6a832a7..0000000 --- a/plugins/User/resources/views/account/verify.php +++ /dev/null @@ -1,78 +0,0 @@ -<?php -/** - * Email-verification page (user::account/verify). POSTs the token to - * /ajx/users/verify (UserController@verifyEmailByToken — unauthenticated). - * - * The token is prefilled from the ?token= query string when the user arrives - * via the emailed link; otherwise they can paste it in manually. - * - * @var string $csrf - * @var string $token - */ -$token = (string) ($token ?? ''); -?> -<div class="card"> - <h2>Verify your email</h2> - <p class="muted"> - Paste the verification token from your email below, or follow the link we - sent you. Submits to <code>POST /ajx/users/verify</code>. - </p> - - <div id="verify-result" style="display:none"></div> - - <form id="verify-form" novalidate> - <input type="hidden" name="_csrf_token" value="<?= htmlspecialchars($csrf ?? '', ENT_QUOTES, 'UTF-8') ?>"> - - <label for="token">Verification token</label> - <input id="token" name="token" autocomplete="off" required - value="<?= htmlspecialchars($token, ENT_QUOTES, 'UTF-8') ?>"> - <div class="field-error" data-for="token"></div> - - <div class="actions"> - <button class="btn btn-primary" type="submit">Verify email</button> - <a class="btn" href="/users">Cancel</a> - </div> - </form> -</div> - -<script> -(function () { - const form = document.getElementById('verify-form'); - const result = document.getElementById('verify-result'); - - function clearErrors() { - form.querySelectorAll('.field-error').forEach(el => el.textContent = ''); - } - function showErrors(fields) { - for (const [name, msg] of Object.entries(fields || {})) { - const el = form.querySelector('.field-error[data-for="' + name + '"]'); - if (el) el.textContent = Array.isArray(msg) ? msg.join(' ') : msg; - } - } - - async function submit(token) { - clearErrors(); - result.style.display = 'none'; - try { - const data = await window.UserApp.request('POST', '/verify', { token }); - result.className = 'alert ok'; - // "already_verified" is only returned for a valid token (proof of - // inbox control), so it is safe to show the distinct message. - result.textContent = data && data.status === 'already_verified' - ? (data.message || 'Your email is already verified — you can sign in.') - : 'Your email has been verified. You can now sign in.'; - result.style.display = 'block'; - form.querySelector('button[type="submit"]').disabled = true; - } catch (e) { - showErrors(e.fields); - window.UserApp.flash(e.message, 'error'); - } - } - - form.addEventListener('submit', (ev) => { - ev.preventDefault(); - const token = form.token.value.trim(); - if (token) submit(token); - }); -})(); -</script> diff --git a/plugins/User/resources/views/emails/verify.php b/plugins/User/resources/views/emails/verify.php deleted file mode 100644 index c243fff..0000000 --- a/plugins/User/resources/views/emails/verify.php +++ /dev/null @@ -1,49 +0,0 @@ -<?php -/** - * Email — verify your address (user::emails/verify). - * - * Rendered by the MailPort when a public signup is queued. `$url` is the - * absolute verification link (host-aware, from Request::site()); the page it - * points at POSTs the token to /ajx/users/verify. - * - * @var string $url - */ -$url = (string) ($url ?? '#'); -?> -<!doctype html> -<html lang="en"> -<body style="margin:0;padding:24px;background:#f5f5f5;font-family:Arial,Helvetica,sans-serif;color:#222;"> - <table role="presentation" width="100%" cellpadding="0" cellspacing="0"> - <tr> - <td align="center"> - <table role="presentation" width="480" cellpadding="0" cellspacing="0" - style="background:#ffffff;border-radius:8px;padding:32px;"> - <tr><td> - <h1 style="margin:0 0 16px;font-size:20px;">Confirm your email</h1> - <p style="margin:0 0 24px;line-height:1.5;"> - Thanks for signing up. Please confirm your email address to activate - your account. This link expires in 24 hours. - </p> - <p style="margin:0 0 24px;"> - <a href="<?= htmlspecialchars($url, ENT_QUOTES) ?>" - style="display:inline-block;background:#2563eb;color:#ffffff; - text-decoration:none;padding:12px 20px;border-radius:6px;"> - Verify email address - </a> - </p> - <p style="margin:0;font-size:13px;color:#666;line-height:1.5;"> - If the button doesn't work, copy this link into your browser:<br> - <a href="<?= htmlspecialchars($url, ENT_QUOTES) ?>" style="color:#2563eb;word-break:break-all;"> - <?= htmlspecialchars($url, ENT_QUOTES) ?> - </a> - </p> - <p style="margin:24px 0 0;font-size:13px;color:#999;"> - If you didn't create this account, you can safely ignore this email. - </p> - </td></tr> - </table> - </td> - </tr> - </table> -</body> -</html> diff --git a/plugins/User/resources/views/layouts/app.php b/plugins/User/resources/views/layouts/app.php deleted file mode 100644 index 6127153..0000000 --- a/plugins/User/resources/views/layouts/app.php +++ /dev/null @@ -1,138 +0,0 @@ -<?php -/** - * User UI layout (resolved as user::layouts/app). - * - * Receives: - * @var string $title Page title. - * @var string $apiBase Base path of the JSON API (e.g. /ajx/users). - * @var string $csrf CSRF token (HMAC, bound to the session cookie). - * @var string $view Rendered child-view HTML (injected by the renderer). - * @var string $seoHead Optional pre-rendered SEO head block (seoPrivate()) — - * owns <title> + robots when present; echo RAW. - */ -$title = $title ?? 'Users'; -$apiBase = $apiBase ?? '/ajx/users'; -$csrf = $csrf ?? ''; -$seoHead = $seoHead ?? ''; -?> -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <meta name="csrf-token" content="<?= htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8') ?>"> - <?php if ($seoHead !== ''): ?> - <?= $seoHead ?> - <?php else: ?> - <title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?> · User - - - - -
-

User Management

- -
- -
-
- -
- - - - diff --git a/plugins/User/resources/views/user.php b/plugins/User/resources/views/user.php deleted file mode 100644 index d487ba7..0000000 --- a/plugins/User/resources/views/user.php +++ /dev/null @@ -1,22 +0,0 @@ -view('user', [...]). - * A project file of the same name overrides this (project-first cascade). - * - * @var array $data - */ -?> - - - - - User - - -

User plugin

-

Edit this view at resources/views/user.php.

- - diff --git a/plugins/User/resources/views/users/create.php b/plugins/User/resources/views/users/create.php deleted file mode 100644 index 1216517..0000000 --- a/plugins/User/resources/views/users/create.php +++ /dev/null @@ -1,66 +0,0 @@ - -
-

Create account

-

Submits to POST /ajx/users (rate-limited, public signup).

- -
- - - - -
- - - -
- - - -
- -
- - Cancel -
-
-
- - diff --git a/plugins/User/resources/views/users/edit.php b/plugins/User/resources/views/users/edit.php deleted file mode 100644 index efde78e..0000000 --- a/plugins/User/resources/views/users/edit.php +++ /dev/null @@ -1,92 +0,0 @@ - -
-

Edit user

-

Partial update — leave password blank to keep it unchanged.

- -
- - - - -
- - - -
- - - -
- -
- - Cancel -
-
-
- - diff --git a/plugins/User/resources/views/users/index.php b/plugins/User/resources/views/users/index.php deleted file mode 100644 index 7ac9b68..0000000 --- a/plugins/User/resources/views/users/index.php +++ /dev/null @@ -1,90 +0,0 @@ - -
-

Users

-

Loaded from GET /ajx/users (requires the auth filter).

- - - - - - - - -
UsernameEmailStatusCreated
Loading…
- -
- Create user - -
-
- - - - diff --git a/plugins/User/resources/views/users/show.php b/plugins/User/resources/views/users/show.php deleted file mode 100644 index 7903c1c..0000000 --- a/plugins/User/resources/views/users/show.php +++ /dev/null @@ -1,67 +0,0 @@ - -
-

User detail

- -
Loading…
- -
- Back to list - Edit - -
-
- - diff --git a/plugins/User/ui/README.md b/plugins/User/ui/README.md deleted file mode 100644 index 83e5b10..0000000 --- a/plugins/User/ui/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# User plugin UI — admin + frontend pages - -The User plugin ships a Pageflow (React) UI with pages for **both faces**: - -``` -plugins/User/ui/ -├─ ui.json alias "@user" + surfaces map { admin: admin/Pages, site: site/Pages } -├─ index.ts barrel — exposes shared bits as @user -├─ components/UserBadge.tsx a shared component (reused by the pages) -├─ admin/Pages/User/ ADMIN surface pages -│ ├─ Index.tsx component "User/Index" (users list) -│ └─ Show.tsx component "User/Show" (user detail) -└─ site/Pages/User/ PUBLIC surface pages - ├─ Register.tsx component "User/Register" (public signup) - └─ Profile.tsx component "User/Profile" (my account) -``` - -## How it reaches a project - -1. **Federation** — `hkm ui sync` mirrors this `ui/` into the project's - `frontend/plugins/user/` and adds the `@user` alias to `tsconfig.plugins.json`. -2. **Per-face discovery** — each surface entry globs the plugin pages for its - face, so no per-page wiring: - - admin surface: `import.meta.glob("../../../plugins/*/admin/Pages/**/*.tsx")` - - public surface: `import.meta.glob("../../../plugins/*/site/Pages/**/*.tsx")` - Project pages are spread first, so a project can override a plugin page. -3. **Server** — the plugin's `UserFlowController` renders the component names: - `render($request, 'User/Index'|'User/Show'|'User/Register'|'User/Profile', …)`. - Routes live in `module.json` with `requires: ["http.pageflow","user.management"]`. - -## Routes (module.json) - -| Method · Path | Face | Component | Filter | -|---|---|---|---| -| GET `/admin/users` | admin | `User/Index` | `auth` | -| GET `/admin/users/{id}` | admin | `User/Show` | `auth` | -| GET `/register` | site | `User/Register` | — | -| GET `/account/profile` | site | `User/Profile` | `auth` | - -`/admin/*` routes render through the **admin** surface; the rest through the -public surface. A single Pageflow shell can pick the surface by URL face (see the -psp-shop `resources/layouts/pageflow.php` — `str_starts_with($FLOW_PAGE->url, '/admin')`). - -Every page's SEO/title is server-driven via the reserved `seoHead` prop -(`UserFlowController`: `/register` gets the full `seoFor()` head; the auth-gated -and token pages get `seoPrivate()` = branded title + noindex). Pages must NOT -set `` — the client syncs the tab title from `seoHead` on every -navigation (see `plugins/Pageflow/README.md`). - -## Authorization - -`UserService::list()` enforces the `user:list` permission (admin-only). Session -login sets no permissions by default — grant them from assigned roles at login -(`AuthService::startSession($session, $id, $roles, $permissions)`), or, for a -demo, via a project stage that injects permissions for authenticated users (see -psp-shop `GrantDemoAdminStage` + `DEMO_ADMIN_PERMISSIONS`). Note: services read -Identity from the request-scoped container (bound at LoadStage, before session -auth), so a stage that elevates permissions must **rebind** `Identity::class` -into `$request->container()`, not just the request. diff --git a/plugins/User/ui/admin/Pages/User/Index.tsx b/plugins/User/ui/admin/Pages/User/Index.tsx deleted file mode 100644 index 076781a..0000000 --- a/plugins/User/ui/admin/Pages/User/Index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { usePage, Head, Link, router } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@ui/table"; -import { UserBadge, type UserSummary } from "@user"; - -// ADMIN page contributed by the User PLUGIN. The admin surface globs -// plugins/*/admin/Pages/**, so this resolves as component "User/Index" even -// though it lives in the plugin. Server: UserFlowController@adminIndex. -type UserRow = UserSummary & { createdAt: string }; - -type IndexProps = { - users: UserRow[]; - hasMore: boolean; - nextCursor: string | null; -}; - -export default function UserIndex() { - const { props } = usePage(); - - return ( - <> - -
-
-

Users

- {props.users.length} shown -
- - - - - User - Email - Joined - Actions - - - - {props.users.map((u) => ( - - - - - {u.email} - {new Date(u.createdAt).toLocaleDateString()} - - - - - ))} - -
- - {props.hasMore && props.nextCursor && ( -
- -
- )} -
- - ); -} diff --git a/plugins/User/ui/admin/Pages/User/Show.tsx b/plugins/User/ui/admin/Pages/User/Show.tsx deleted file mode 100644 index 8c79b60..0000000 --- a/plugins/User/ui/admin/Pages/User/Show.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { usePage, useForm, Head, Link } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Card, CardContent, CardHeader } from "@ui/card"; -import { UserBadge, type UserSummary } from "@user"; - -type ShowProps = { user: (UserSummary & { createdAt: string }) | null }; - -export default function UserShow() { - const { props } = usePage(); - const form = useForm({}); - - if (!props.user) { - return ( -
-

User not found.

- -
- ); - } - - const u = props.user; - function verify() { - form.post(`/ajx/users/${u.id}/verify-email`, { preserveScroll: true }); - } - - return ( - <> - -
- - - - - {!u.emailVerified && ( - - )} - - -
-
ID
-
{u.id}
-
Email
-
{u.email}
-
Joined
-
{new Date(u.createdAt).toLocaleString()}
-
-
-
-
- - ); -} diff --git a/plugins/User/ui/components/UserBadge.tsx b/plugins/User/ui/components/UserBadge.tsx deleted file mode 100644 index 127f297..0000000 --- a/plugins/User/ui/components/UserBadge.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// A shared component the plugin EXPOSES to every surface. Consumers import it -// as `@user` (the plugin's federated alias). Built on the project's shared shadcn -// design system (@ui) — so plugin UI and project UI stay visually consistent. -import { Avatar, AvatarFallback } from "@ui/avatar"; -import { Badge } from "@ui/badge"; - -export interface UserSummary { - id: string; - username: string; - email: string; - emailVerified: boolean; -} - -export function UserBadge({ user }: { user: UserSummary }) { - const initials = user.username.slice(0, 2).toUpperCase(); - return ( - - - - {initials} - - - {user.username} - {user.emailVerified ? ( - - verified - - ) : ( - - pending - - )} - - ); -} diff --git a/plugins/User/ui/index.ts b/plugins/User/ui/index.ts deleted file mode 100644 index ec8b3b5..0000000 --- a/plugins/User/ui/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Public barrel for the User plugin's UI — reachable from any surface as -// `@user` / `@user/components/...` once `hkm ui sync` has federated it. -// The plugin exposes shared building blocks here; its PAGES live under -// admin/Pages (admin surface) and site/Pages (public surface). -export { UserBadge } from "./components/UserBadge"; -export type { UserSummary } from "./components/UserBadge"; diff --git a/plugins/User/ui/site/Pages/User/Profile.tsx b/plugins/User/ui/site/Pages/User/Profile.tsx deleted file mode 100644 index 837f3c0..0000000 --- a/plugins/User/ui/site/Pages/User/Profile.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { usePage, Head, Link } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Card, CardContent } from "@ui/card"; -import { Alert, AlertDescription } from "@ui/alert"; -import { UserBadge, type UserSummary } from "@user"; - -// PUBLIC "my account" page (component "User/Profile"). Server: -// UserFlowController@profile — reads the authenticated Identity and loads it. -type ProfileProps = { user: (UserSummary & { createdAt: string }) | null }; - -export default function Profile() { - const { props } = usePage(); - - if (!props.user) { - return ( -
-

You are not signed in.

- -
- ); - } - - const u = props.user; - return ( - <> - -
-

Your profile

- - - -
-
-
Email
-
{u.email}
-
-
-
Member since
-
{new Date(u.createdAt).toLocaleDateString()}
-
-
-
-
- {!u.emailVerified && ( - - Please verify your email to unlock all features. - - )} -
- - ); -} diff --git a/plugins/User/ui/site/Pages/User/Register.tsx b/plugins/User/ui/site/Pages/User/Register.tsx deleted file mode 100644 index 099fcb0..0000000 --- a/plugins/User/ui/site/Pages/User/Register.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useForm, Head, Link } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Input } from "@ui/input"; -import { Label } from "@ui/label"; -import { Card, CardContent, CardHeader, CardTitle } from "@ui/card"; - -// PUBLIC page contributed by the User PLUGIN. The public surface globs -// plugins/*/site/Pages/**, so this resolves as component "User/Register". -// Server: UserFlowController@register. Posts to the plugin's own /ajx/users. -export default function Register() { - const form = useForm({ username: "", email: "", password: "" }); - - function submit(e: React.FormEvent) { - e.preventDefault(); - form.post("/ajx/users"); - } - - return ( - <> - -
- - - Create your account - - - {form.wasSuccessful ? ( -
-

- Almost there — we've emailed you a verification link. - Follow it, or enter the token to confirm your address. -

- -
- ) : ( -
- - form.setData("username", e.target.value)} - /> - - - form.setData("email", e.target.value)} - /> - - - form.setData("password", e.target.value)} - /> - - -
- )} -
-
-

- Already have an account?{" "} - -

-
- - ); -} - -function Field({ - label, - htmlFor, - error, - children, -}: { - label: string; - htmlFor: string; - error?: string; - children: React.ReactNode; -}) { - return ( -
- - {children} - {error &&

{error}

} -
- ); -} diff --git a/plugins/User/ui/site/Pages/User/VerifyEmail.tsx b/plugins/User/ui/site/Pages/User/VerifyEmail.tsx deleted file mode 100644 index fd1fe5c..0000000 --- a/plugins/User/ui/site/Pages/User/VerifyEmail.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useForm, Head, Link } from "@pageflow/react"; -import { Button } from "@ui/button"; -import { Input } from "@ui/input"; -import { Label } from "@ui/label"; -import { Card, CardContent, CardHeader, CardTitle } from "@ui/card"; - -// PUBLIC page contributed by the User PLUGIN. The public surface globs -// plugins/*/site/Pages/**, so this resolves as component "User/VerifyEmail". -// Server: UserFlowController@verifyEmail. The emailed link points at -// /verify-email?token=... — the server passes `token` as a prop for prefill. -// Posts to the plugin's own /ajx/users/verify (UserController@verifyEmailByToken). -export default function VerifyEmail({ token = "" }: { token?: string }) { - const form = useForm({ token }); - - function submit(e: React.FormEvent) { - e.preventDefault(); - form.post("/ajx/users/verify"); - } - - return ( - <> - -
- - - Verify your email - - -

- Paste the verification token from your email below, or follow the - link we sent you. -

- {form.wasSuccessful ? ( -
-

- Your email has been verified. You can now sign in. -

- -
- ) : ( -
-
- - form.setData("token", e.target.value)} - /> - {form.errors.token && ( -

{form.errors.token}

- )} -
- -
- )} -
-
-

- Need a new account?{" "} - -

-
- - ); -} diff --git a/plugins/User/ui/ui.json b/plugins/User/ui/ui.json deleted file mode 100644 index 09d6441..0000000 --- a/plugins/User/ui/ui.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "alias": "@user", - "entry": "index.ts", - "framework": "react", - "surfaces": { - "admin": "admin/Pages", - "site": "site/Pages" - }, - "dependencies": {} -} diff --git a/plugins/Validation/AbstractDto.php b/plugins/Validation/AbstractDto.php deleted file mode 100644 index ed42aff..0000000 --- a/plugins/Validation/AbstractDto.php +++ /dev/null @@ -1,93 +0,0 @@ - 'nullable|string|max:80']; - * } - * - * public static function fromRequest(Request $request): self - * { - * $v = static::validated($request); // throws 422 on bad input - * return new self(firstName: $v['firstName'] ?? null); - * } - * } - */ -abstract readonly class AbstractDto -{ - /** - * Field => rule(s), e.g. ['email' => 'required|email|max:150']. - * - * @return array> - */ - abstract protected static function rules(): array; - - /** - * Optional custom "field.rule" => message overrides. - * - * @return array - */ - protected static function messages(): array - { - return []; - } - - /** - * Validate the request (body + query merged) against rules(). Throws - * ValidationException (kernel 422) on failure; returns the validated map. - * - * @return array - */ - final protected static function validated(Request $request, ?Translator $translator = null): array - { - return static::validate($request->all(), $translator); - } - - /** - * Validate a raw input array — for callers that already have the map (jobs, - * CLI, sub-DTOs) and no Request. - * - * @param array $input - * @return array - */ - final protected static function validate(array $input, ?Translator $translator = null): array - { - return Validator::make($input, static::rules(), static::messages(), $translator)->validate(); - } - - /** - * Validate and RETURN the error map WITHOUT throwing — so a DTO can merge in - * domain-level errors (value-object / policy failures) and raise a single - * combined ValidationException. Empty array = shape is valid. - * - * @param array $input - * @return array> - */ - final protected static function collectErrors(array $input, ?Translator $translator = null): array - { - return Validator::make($input, static::rules(), static::messages(), $translator)->errors(); - } -} diff --git a/plugins/Validation/Provider.php b/plugins/Validation/Provider.php deleted file mode 100644 index d026fa6..0000000 --- a/plugins/Validation/Provider.php +++ /dev/null @@ -1,80 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return []; - } - - public function register(ModuleContainer $container): void - { - // Nothing to bind — the Validator is used statically at the DTO boundary. - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - $config = $this->config(); - - // CI $ruleSets — register every rule-provider class. - foreach ($config['rulesets'] ?? [] as $ruleSet) { - Validator::extendWith($ruleSet); - } - - // CI rule groups — register each named {rules, messages} set. - foreach ($config['groups'] ?? [] as $name => $group) { - Validator::defineGroup($name, $group['rules'] ?? [], $group['messages'] ?? []); - } - } - - /** @return array{rulesets?: list, groups?: array} */ - private function config(): array - { - // From the compiled config manifest, where the project's config/validation.php - // is DEEP-MERGED over this plugin's — so a project adding one rule group - // keeps the shipped rulesets instead of replacing the file wholesale. - $config = \function_exists('config') ? config('validation') : null; - - if (\is_array($config) && $config !== []) { - return $config; - } - - // No manifest (e.g. a unit test that never ran the BootPipeline). - /** @var array $fallback */ - $fallback = require __DIR__ . '/config/validation.php'; - - return \is_array($fallback) ? $fallback : []; - } -} diff --git a/plugins/Validation/README.md b/plugins/Validation/README.md deleted file mode 100644 index 03f29dd..0000000 --- a/plugins/Validation/README.md +++ /dev/null @@ -1,159 +0,0 @@ -# Validation plugin - -A shared, dependency-free request-validation engine. It produces the kernel's -standard `ValidationException` (a 422 with `{ field: [messages] }`), so DTOs stop -hand-rolling `$errors[]` accumulation. - -- `Validator` — the rule engine (a plain autoloaded class; validation is DI-free - boundary logic, so it needs no container). -- `AbstractDto` — base class for request DTOs: declare `rules()`, call - `validated($request)`. -- `config/validation.php` — CodeIgniter's `Config\Validation` equivalent - (rule-sets + rule-groups), loaded once at boot by `Provider`. - -## 1. Validating a DTO - -```php -use Plugins\Validation\AbstractDto; - -final readonly class UpdateProfileDTO extends AbstractDto -{ - public function __construct(public ?string $firstName, public ?string $avatarUrl) {} - - protected static function rules(): array - { - return [ - 'firstName' => 'nullable|string|max:80', - 'avatarUrl' => 'nullable|http_url|max:500', - ]; - } - - protected static function messages(): array // optional per-rule overrides - { - return ['avatarUrl.http_url' => 'Avatar must be an http(s) URL.']; - } - - public static function fromRequest(Request $request): self - { - static::validated($request); // throws 422 on bad shape - return new self($request->input('firstName'), $request->input('avatarUrl')); - } -} -``` - -**Division of labour:** rules validate *shape* (required / type / length / -format); deep *domain* invariants stay in the value objects the DTO constructs. - -## 2. Built-in rules - -``` -required nullable string integer numeric boolean array -email url http_url timezone -min:n max:n between:a,b in:a,b,c regex:/.../ -same:field different:field confirmed enum:Class -``` - -## 3. Adding rules — three ways (all register ONCE at boot) - -### a. A single closure - -```php -Validator::extend('even', - fn($value) => (int) $value % 2 === 0, - 'The :field must be even.'); -// use it: 'quantity' => 'required|even' -``` - -### b. A rule-set CLASS (CodeIgniter style) - -Every public method becomes a rule; `messages()` supplies defaults. - -```php -final class CommonRules -{ - public function slug(mixed $v, ?string $p, array $data): bool - { - return is_string($v) && preg_match('/^[a-z0-9-]+$/', $v) === 1; - } - public function messages(): array - { - return ['slug' => 'The :field must be kebab-case.']; - } -} - -Validator::extendWith(CommonRules::class); // registers slug + any other methods -``` - -Shipped rule-set packs (both enabled by default via `config/validation.php`), -mirroring how CodeIgniter splits FormatRules / Rules / CreditCardRules: - -**`CommonRules`** — universal rules on top of the built-ins: - -```text -alpha alpha_num alpha_dash alpha_space alpha_numeric_punct ascii lowercase uppercase -digits[:n] digits_between:a,b is_natural is_natural_no_zero hex decimal[:p|:a,b] -multiple_of:n min_digits:n max_digits:n size:n gt:n gte:n lt:n lte:n -starts_with:… ends_with:… doesnt_start_with:… doesnt_end_with:… not_in:… -distinct list -uuid ulid slug username -ip ipv4 ipv6 mac_address domain -json base64 hex_color -date date_format:Y-m-d before:… after:… -accepted declined locale currency phone e164 -``` - -**`FinancialRules`** — money / payment fields: - -```text -luhn credit_card cvv iban bic -``` - -> Cross-field presence rules (`required_if` / `required_with`) are intentionally -> not provided — the engine skips value rules on absent fields, so they can't be -> expressed as extensions. Enforce those in the service layer. - -Rule method signature: `(mixed $value, ?string $param, array $data): bool`. -`$param` is the `:arg` in `rule:arg`; `$data` is the full input (cross-field rules). - -### c. Named rule GROUPS (CodeIgniter rule groups) - -Reusable `{rules, messages}` sets addressed by name: - -```php -Validator::defineGroup('login', [ - 'email' => 'required|email', - 'password' => 'required|string|min:8', -], ['email.required' => 'We need your email.']); - -Validator::group('login', $request->all())->validate(); -``` - -## 4. Configuration — `config/validation.php` - -The CI `Config\Validation` equivalent. The `Provider` reads it at boot and wires -it in — no core edits, no per-request cost. A project may override it by placing -its own `config/validation.php` (resolved via `config_path()`). - -```php -return [ - 'rulesets' => [ CommonRules::class ], // CI $ruleSets → extendWith() - 'groups' => [ // CI rule groups → defineGroup() - 'login' => ['rules' => [...], 'messages' => [...]], - ], -]; -``` - -## Message resolution order (per failed rule) - -1. DTO/`make()` override — `messages["{field}.{rule}"]` -2. I18n translator — `validation.{rule}` (when a `Translator` is passed) -3. built-in default, then a registered custom-rule default - -## Notes - -- `extend` / `extendWith` / `defineGroup` use a **process-wide static** registry: - register at bootstrap (a Provider `boot()` / project bootstrap), never - per-request — safe and cheap under OpenSwoole. `flushExtensions()` is a test - helper only. -- Unknown rules **pass** rather than fail hard, so a rule typo never 422s a whole - request surface by accident. diff --git a/plugins/Validation/Rules/CommonRules.php b/plugins/Validation/Rules/CommonRules.php deleted file mode 100644 index 58e0119..0000000 --- a/plugins/Validation/Rules/CommonRules.php +++ /dev/null @@ -1,473 +0,0 @@ - $data): bool - */ -final class CommonRules -{ - // ── character classes ──────────────────────────────────────────────────── - - /** Unicode letters only. */ - public function alpha(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^\p{L}+$/u', $v) === 1; - } - - /** Unicode letters and numbers. */ - public function alpha_num(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[\p{L}\p{N}]+$/u', $v) === 1; - } - - /** Letters, numbers, dashes and underscores (slug-safe identifiers). */ - public function alpha_dash(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[\p{L}\p{N}_-]+$/u', $v) === 1; - } - - /** Letters and spaces (human names). */ - public function alpha_space(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[\p{L} ]+$/u', $v) === 1; - } - - /** Letters, numbers, spaces and common punctuation (free text, CI parity). */ - public function alpha_numeric_punct(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) - && \preg_match('/^[\p{L}\p{N} ~!#$%&*\-_+=|:.;,?@\'"\/()\[\]{}]+$/u', $v) === 1; - } - - /** 7-bit ASCII only. */ - public function ascii(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[\x00-\x7F]*$/', $v) === 1; - } - - /** Already lowercase. */ - public function lowercase(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \mb_strtolower($v) === $v; - } - - /** Already uppercase. */ - public function uppercase(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \mb_strtoupper($v) === $v; - } - - // ── numbers / sizes ────────────────────────────────────────────────────── - - /** All digits; with `digits:n`, exactly n digits. */ - public function digits(mixed $v, ?string $p, array $d): bool - { - $s = (string) $v; - if (\ctype_digit($s) === false) { - return false; - } - return $p === null || \mb_strlen($s) === (int) $p; - } - - /** `digits_between:a,b` — all digits, length within [a,b]. */ - public function digits_between(mixed $v, ?string $p, array $d): bool - { - $s = (string) $v; - if (\ctype_digit($s) === false) { - return false; - } - [$a, $b] = \array_pad(\explode(',', (string) $p), 2, '0'); - $len = \mb_strlen($s); - return $len >= (int) $a && $len <= (int) $b; - } - - /** `size:n` — string length / array count / number equals n. */ - public function size(mixed $v, ?string $p, array $d): bool - { - return $this->measure($v) === (float) $p; - } - - /** `gt:n` — numeric/size strictly greater than n. */ - public function gt(mixed $v, ?string $p, array $d): bool - { - return $this->measure($v) > (float) $p; - } - - /** `gte:n` — numeric/size greater than or equal to n. */ - public function gte(mixed $v, ?string $p, array $d): bool - { - return $this->measure($v) >= (float) $p; - } - - /** `lt:n` — numeric/size strictly less than n. */ - public function lt(mixed $v, ?string $p, array $d): bool - { - return $this->measure($v) < (float) $p; - } - - /** `lte:n` — numeric/size less than or equal to n. */ - public function lte(mixed $v, ?string $p, array $d): bool - { - return $this->measure($v) <= (float) $p; - } - - /** Non-negative integer (0, 1, 2, …). */ - public function is_natural(mixed $v, ?string $p, array $d): bool - { - return \ctype_digit((string) $v); - } - - /** Positive integer (1, 2, 3, …). */ - public function is_natural_no_zero(mixed $v, ?string $p, array $d): bool - { - return \ctype_digit((string) $v) && (int) $v > 0; - } - - /** Hexadecimal string. */ - public function hex(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && $v !== '' && \ctype_xdigit($v); - } - - /** - * Decimal number. `decimal` = any; `decimal:2` = exactly 2 places; - * `decimal:1,4` = between 1 and 4 places. - */ - public function decimal(mixed $v, ?string $p, array $d): bool - { - if (\preg_match('/^-?\d+(?:\.(\d+))?$/', (string) $v, $m) !== 1) { - return false; - } - if ($p === null) { - return true; - } - $places = isset($m[1]) ? \strlen($m[1]) : 0; - [$min, $max] = \array_pad(\explode(',', $p), 2, null); - $max ??= $min; - - return $places >= (int) $min && $places <= (int) $max; - } - - /** `multiple_of:n` — numeric and evenly divisible by n. */ - public function multiple_of(mixed $v, ?string $p, array $d): bool - { - if (!\is_numeric($v) || !\is_numeric($p) || (float) $p === 0.0) { - return false; - } - return \fmod((float) $v, (float) $p) === 0.0; - } - - /** `min_digits:n` — numeric with at least n digits. */ - public function min_digits(mixed $v, ?string $p, array $d): bool - { - $digits = \preg_replace('/\D/', '', (string) $v); - return $digits !== '' && \strlen($digits) >= (int) $p; - } - - /** `max_digits:n` — numeric with at most n digits. */ - public function max_digits(mixed $v, ?string $p, array $d): bool - { - $digits = \preg_replace('/\D/', '', (string) $v); - return \strlen((string) $digits) <= (int) $p; - } - - // ── strings / membership ───────────────────────────────────────────────── - - /** `starts_with:a,b,c` — begins with any of the listed prefixes. */ - public function starts_with(mixed $v, ?string $p, array $d): bool - { - $s = (string) $v; - foreach (\explode(',', (string) $p) as $needle) { - if ($needle !== '' && \str_starts_with($s, $needle)) { - return true; - } - } - return false; - } - - /** `ends_with:a,b,c` — ends with any of the listed suffixes. */ - public function ends_with(mixed $v, ?string $p, array $d): bool - { - $s = (string) $v; - foreach (\explode(',', (string) $p) as $needle) { - if ($needle !== '' && \str_ends_with($s, $needle)) { - return true; - } - } - return false; - } - - /** `doesnt_start_with:a,b` — begins with none of the listed prefixes. */ - public function doesnt_start_with(mixed $v, ?string $p, array $d): bool - { - return $this->starts_with($v, $p, $d) === false; - } - - /** `doesnt_end_with:a,b` — ends with none of the listed suffixes. */ - public function doesnt_end_with(mixed $v, ?string $p, array $d): bool - { - return $this->ends_with($v, $p, $d) === false; - } - - /** `not_in:a,b,c` — value is NOT one of the listed options. */ - public function not_in(mixed $v, ?string $p, array $d): bool - { - return \in_array((string) $v, \explode(',', (string) $p), true) === false; - } - - // ── arrays ─────────────────────────────────────────────────────────────── - - /** Array whose values are all unique. */ - public function distinct(mixed $v, ?string $p, array $d): bool - { - return \is_array($v) && \count($v) === \count(\array_unique($v, SORT_REGULAR)); - } - - /** Array that is a sequential list (0,1,2… keys), not a map. */ - public function list(mixed $v, ?string $p, array $d): bool - { - return \is_array($v) && \array_is_list($v); - } - - // ── identifiers ────────────────────────────────────────────────────────── - - /** RFC 4122 UUID (any version). */ - public function uuid(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) - && \preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $v) === 1; - } - - /** ULID — 26 Crockford base32 chars. */ - public function ulid(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i', $v) === 1; - } - - /** lowercase kebab-case slug. */ - public function slug(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $v) === 1; - } - - /** Username — 3+ chars of letters, numbers, underscore. */ - public function username(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[A-Za-z0-9_]{3,}$/', $v) === 1; - } - - // ── network ────────────────────────────────────────────────────────────── - - /** Any IP address (v4 or v6). */ - public function ip(mixed $v, ?string $p, array $d): bool - { - return \filter_var($v, FILTER_VALIDATE_IP) !== false; - } - - public function ipv4(mixed $v, ?string $p, array $d): bool - { - return \filter_var($v, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; - } - - public function ipv6(mixed $v, ?string $p, array $d): bool - { - return \filter_var($v, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false; - } - - /** MAC address. */ - public function mac_address(mixed $v, ?string $p, array $d): bool - { - return \filter_var($v, FILTER_VALIDATE_MAC) !== false; - } - - /** DNS hostname / domain. */ - public function domain(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) - && \preg_match('/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i', $v) === 1; - } - - // ── formats ────────────────────────────────────────────────────────────── - - /** Valid JSON string. */ - public function json(mixed $v, ?string $p, array $d): bool - { - if (\is_string($v) === false || $v === '') { - return false; - } - \json_decode($v); - return \json_last_error() === JSON_ERROR_NONE; - } - - /** Base64-encoded string. */ - public function base64(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \base64_decode($v, true) !== false; - } - - /** CSS hex colour (#rgb or #rrggbb). */ - public function hex_color(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i', $v) === 1; - } - - // ── dates ──────────────────────────────────────────────────────────────── - - /** Any parseable date/time. */ - public function date(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && $v !== '' && \strtotime($v) !== false; - } - - /** `date_format:Y-m-d` — matches the exact format. */ - public function date_format(mixed $v, ?string $p, array $d): bool - { - if (\is_string($v) === false || $p === null) { - return false; - } - $dt = \DateTimeImmutable::createFromFormat('!' . $p, $v); - return $dt !== false && $dt->format($p) === $v; - } - - /** `before:2030-01-01` (or `before:today`) — strictly earlier. */ - public function before(mixed $v, ?string $p, array $d): bool - { - $a = \strtotime((string) $v); - $b = \strtotime((string) $p); - return $a !== false && $b !== false && $a < $b; - } - - /** `after:2000-01-01` (or `after:today`) — strictly later. */ - public function after(mixed $v, ?string $p, array $d): bool - { - $a = \strtotime((string) $v); - $b = \strtotime((string) $p); - return $a !== false && $b !== false && $a > $b; - } - - // ── booleans / acceptance ──────────────────────────────────────────────── - - /** Truthy consent: yes / on / 1 / true. */ - public function accepted(mixed $v, ?string $p, array $d): bool - { - return \in_array($v, ['yes', 'on', '1', 1, true, 'true'], true); - } - - /** Falsy: no / off / 0 / false. */ - public function declined(mixed $v, ?string $p, array $d): bool - { - return \in_array($v, ['no', 'off', '0', 0, false, 'false'], true); - } - - // ── locale / money / contact ───────────────────────────────────────────── - - /** ll_CC locale tag, e.g. en_US. */ - public function locale(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[a-z]{2}_[A-Z]{2}$/', $v) === 1; - } - - /** ISO 4217 currency code, e.g. UGX. */ - public function currency(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^[A-Z]{3}$/', $v) === 1; - } - - /** Loose phone: optional +, 7–15 digits. */ - public function phone(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^\+?[0-9]{7,15}$/', $v) === 1; - } - - /** Strict E.164: leading +, 1–15 digits, no leading zero. */ - public function e164(mixed $v, ?string $p, array $d): bool - { - return \is_string($v) && \preg_match('/^\+[1-9]\d{1,14}$/', $v) === 1; - } - - /** @return array per-rule default messages (:field / :param). */ - public function messages(): array - { - return [ - 'alpha' => 'The :field field may only contain letters.', - 'alpha_num' => 'The :field field may only contain letters and numbers.', - 'alpha_dash' => 'The :field field may only contain letters, numbers, dashes and underscores.', - 'alpha_space' => 'The :field field may only contain letters and spaces.', - 'alpha_numeric_punct' => 'The :field field contains an invalid character.', - 'ascii' => 'The :field field may only contain ASCII characters.', - 'lowercase' => 'The :field field must be lowercase.', - 'uppercase' => 'The :field field must be uppercase.', - 'digits' => 'The :field field must be all digits.', - 'digits_between' => 'The :field field has an invalid number of digits.', - 'is_natural' => 'The :field field must be a non-negative whole number.', - 'is_natural_no_zero' => 'The :field field must be a positive whole number.', - 'hex' => 'The :field field must be hexadecimal.', - 'decimal' => 'The :field field must be a decimal number.', - 'multiple_of' => 'The :field field must be a multiple of :param.', - 'min_digits' => 'The :field field must have at least :param digits.', - 'max_digits' => 'The :field field must not exceed :param digits.', - 'size' => 'The :field field must be of size :param.', - 'gt' => 'The :field field must be greater than :param.', - 'gte' => 'The :field field must be at least :param.', - 'lt' => 'The :field field must be less than :param.', - 'lte' => 'The :field field must not be greater than :param.', - 'starts_with' => 'The :field field has an invalid prefix.', - 'ends_with' => 'The :field field has an invalid suffix.', - 'doesnt_start_with' => 'The :field field has a forbidden prefix.', - 'doesnt_end_with' => 'The :field field has a forbidden suffix.', - 'not_in' => 'The selected :field is invalid.', - 'enum' => 'The selected :field is invalid.', - 'distinct' => 'The :field field has duplicate values.', - 'list' => 'The :field field must be a list.', - 'uuid' => 'The :field field must be a valid UUID.', - 'ulid' => 'The :field field must be a valid ULID.', - 'slug' => 'The :field field must be a lowercase kebab-case slug.', - 'username' => 'The :field field must be 3+ letters, numbers or underscores.', - 'ip' => 'The :field field must be a valid IP address.', - 'ipv4' => 'The :field field must be a valid IPv4 address.', - 'ipv6' => 'The :field field must be a valid IPv6 address.', - 'mac_address' => 'The :field field must be a valid MAC address.', - 'domain' => 'The :field field must be a valid domain.', - 'json' => 'The :field field must be valid JSON.', - 'base64' => 'The :field field must be valid base64.', - 'hex_color' => 'The :field field must be a valid hex colour.', - 'date' => 'The :field field must be a valid date.', - 'date_format' => 'The :field field does not match the required format.', - 'before' => 'The :field field must be a date before :param.', - 'after' => 'The :field field must be a date after :param.', - 'accepted' => 'The :field field must be accepted.', - 'declined' => 'The :field field must be declined.', - 'locale' => 'The :field field must be a locale like en_US.', - 'currency' => 'The :field field must be a 3-letter ISO 4217 code.', - 'phone' => 'The :field field must be 7–15 digits (optional leading +).', - 'e164' => 'The :field field must be a valid E.164 phone number.', - ]; - } - - /** String length / array count / numeric value, used by size/gt/gte/lt/lte. */ - private function measure(mixed $value): float - { - if (\is_numeric($value)) { - return (float) $value; - } - if (\is_array($value)) { - return (float) \count($value); - } - return (float) \mb_strlen((string) $value); - } -} diff --git a/plugins/Validation/Rules/FinancialRules.php b/plugins/Validation/Rules/FinancialRules.php deleted file mode 100644 index 6cd52c0..0000000 --- a/plugins/Validation/Rules/FinancialRules.php +++ /dev/null @@ -1,100 +0,0 @@ - $data): bool - */ -final class FinancialRules -{ - /** Raw Luhn (mod-10) checksum over the digits in the value. */ - public function luhn(mixed $v, ?string $p, array $d): bool - { - $digits = \preg_replace('/\D/', '', (string) $v); - - return $digits !== '' && self::passesLuhn($digits); - } - - /** Credit-card number: 12–19 digits AND a valid Luhn checksum. */ - public function credit_card(mixed $v, ?string $p, array $d): bool - { - $digits = \preg_replace('/\D/', '', (string) $v); - $len = \strlen((string) $digits); - - return $len >= 12 && $len <= 19 && self::passesLuhn($digits); - } - - /** CVV / CVC — 3 or 4 digits. */ - public function cvv(mixed $v, ?string $p, array $d): bool - { - return \preg_match('/^[0-9]{3,4}$/', (string) $v) === 1; - } - - /** IBAN — format + ISO 7064 mod-97 checksum. */ - public function iban(mixed $v, ?string $p, array $d): bool - { - $iban = \strtoupper(\preg_replace('/\s+/', '', (string) $v)); - if (\preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/', $iban) !== 1) { - return false; - } - - // Move the first 4 chars to the end, then replace letters with 10–35. - $rearranged = \substr($iban, 4) . \substr($iban, 0, 4); - $numeric = ''; - foreach (\str_split($rearranged) as $char) { - $numeric .= \ctype_alpha($char) ? (string) (\ord($char) - 55) : $char; - } - - // mod 97 over a long numeric string, chunked to stay in int range. - $remainder = 0; - foreach (\str_split($numeric, 7) as $chunk) { - $remainder = (int) (($remainder . $chunk) % 97); - } - - return $remainder === 1; - } - - /** SWIFT/BIC — 8 or 11 alphanumerics (AAAA BB CC [DDD]). */ - public function bic(mixed $v, ?string $p, array $d): bool - { - return \preg_match('/^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?$/', \strtoupper((string) $v)) === 1; - } - - /** @return array */ - public function messages(): array - { - return [ - 'luhn' => 'The :field field failed its checksum.', - 'credit_card' => 'The :field field must be a valid card number.', - 'cvv' => 'The :field field must be a 3 or 4 digit security code.', - 'iban' => 'The :field field must be a valid IBAN.', - 'bic' => 'The :field field must be a valid BIC/SWIFT code.', - ]; - } - - /** Luhn (mod-10) over a pure-digit string. */ - private static function passesLuhn(string $digits): bool - { - $sum = 0; - $alt = false; - for ($i = \strlen($digits) - 1; $i >= 0; $i--) { - $n = (int) $digits[$i]; - if ($alt) { - $n *= 2; - if ($n > 9) { - $n -= 9; - } - } - $sum += $n; - $alt = !$alt; - } - - return $sum % 10 === 0; - } -} diff --git a/plugins/Validation/Validator.php b/plugins/Validation/Validator.php deleted file mode 100644 index 3e75133..0000000 --- a/plugins/Validation/Validator.php +++ /dev/null @@ -1,418 +0,0 @@ - messages) so - * controllers/DTOs get the framework's standard 422 error shape without each - * one hand-rolling checks. - * - * Message resolution order per failed rule: - * 1. custom override messages["{field}.{rule}"] - * 2. Translator "validation.{rule}" (when a Translator is provided) - * 3. built-in English default - * - * Usage: - * Validator::make($request->all(), [ - * 'email' => 'required|email', - * 'age' => 'required|integer|min:18', - * 'password' => 'required|string|min:8|confirmed', - * ])->validate(); // throws ValidationException on failure, returns validated data on success - * - * Supported rules: - * required, nullable, string, integer, numeric, boolean, array, email, url, - * http_url, timezone, min:n, max:n, between:a,b, in:a,b,c, regex:/.../, - * same:field, different:field, confirmed - * - * Custom rules — register once at bootstrap: - * Validator::extend('kebab', - * fn($v) => is_string($v) && preg_match('/^[a-z0-9-]+$/', $v) === 1, - * 'The :field must be kebab-case.'); - * // then: 'slug' => 'required|kebab' - */ -final class Validator -{ - /** @var array> */ - private array $errors = []; - - /** - * Custom rules registered via extend(). Process-wide (static) so a rule is - * available to EVERY validator instance without re-registering. - * - * @var array $data): bool> - */ - private static array $extensions = []; - - /** Default messages for custom rules, keyed by rule name. @var array */ - private static array $extensionMessages = []; - - /** - * Named rule GROUPS (CodeIgniter-style): a reusable {rules, messages} set - * addressed by name via group(). Populated from config/validation.php. - * - * @var array>, messages: array}> - */ - private static array $groups = []; - - /** Built-in English defaults; :field and rule params are interpolated. */ - private const DEFAULTS = [ - 'required' => 'The :field field is required.', - 'string' => 'The :field field must be a string.', - 'integer' => 'The :field field must be an integer.', - 'numeric' => 'The :field field must be numeric.', - 'boolean' => 'The :field field must be true or false.', - 'array' => 'The :field field must be an array.', - 'email' => 'The :field field must be a valid email address.', - 'url' => 'The :field field must be a valid URL.', - 'http_url' => 'The :field field must be a valid http(s) URL.', - 'timezone' => 'The :field field must be a valid timezone.', - 'enum' => 'The selected :field is invalid.', - 'min' => 'The :field field must be at least :min.', - 'max' => 'The :field field must not be greater than :max.', - 'between' => 'The :field field must be between :min and :max.', - 'in' => 'The selected :field is invalid.', - 'regex' => 'The :field field format is invalid.', - 'same' => 'The :field field must match :other.', - 'different' => 'The :field field must be different from :other.', - 'confirmed' => 'The :field field confirmation does not match.', - ]; - - /** - * @param array $data - * @param array> $rules - * @param array $messages custom "field.rule" => message overrides - */ - public function __construct( - private readonly array $data, - private readonly array $rules, - private readonly array $messages = [], - private readonly ?Translator $translator = null, - ) { - } - - /** - * @param array $data - * @param array> $rules - * @param array $messages - */ - public static function make(array $data, array $rules, array $messages = [], ?Translator $translator = null): self - { - return new self($data, $rules, $messages, $translator); - } - - /** - * Register a CUSTOM rule. - * - * The callback receives the field value, the optional `:param` (e.g. the - * `5` in `starts_with:5`), and the full input map (for cross-field rules). - * Return true to pass, false to fail. Use it like any built-in rule: - * `'slug' => 'required|kebab'`. - * - * Call this ONCE at bootstrap (a Provider::boot / project bootstrap) — the - * registry is static and process-wide, so registering per-request is both - * wasteful and unsafe under OpenSwoole. Registration is idempotent (same - * name overwrites). - * - * @param callable(mixed $value, ?string $param, array $data): bool $validator - * @param string|null $message Default message; supports :field and :param. - */ - public static function extend(string $rule, callable $validator, ?string $message = null): void - { - self::$extensions[$rule] = $validator; - if ($message !== null) { - self::$extensionMessages[$rule] = $message; - } - } - - /** - * Register a RULE-SET class (CodeIgniter-style): every public method on the - * class becomes a rule named after the method. Each method has the signature - * `(mixed $value, ?string $param, array $data): bool`. An - * optional `messages(): array` method supplies per-rule - * default messages. Register once at bootstrap. - * - * @param object|class-string $ruleSet instance or class-string to construct - */ - public static function extendWith(object|string $ruleSet): void - { - $instance = is_string($ruleSet) ? new $ruleSet() : $ruleSet; - - $messages = method_exists($instance, 'messages') ? $instance->messages() : []; - - foreach ((new \ReflectionClass($instance))->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { - $name = $method->getName(); - // Skip framework hooks + magic methods — only rule methods register. - if ($name === 'messages' || str_starts_with($name, '__')) { - continue; - } - self::extend($name, $instance->{$name}(...), $messages[$name] ?? null); - } - } - - /** - * Define a reusable named rule GROUP (CodeIgniter rule groups). Address it - * later with group(). Typically populated from config/validation.php. - * - * @param array> $rules - * @param array $messages - */ - public static function defineGroup(string $name, array $rules, array $messages = []): void - { - self::$groups[$name] = ['rules' => $rules, 'messages' => $messages]; - } - - /** - * Build a validator from a previously defined named group. - * - * @param array $data - */ - public static function group(string $name, array $data, ?Translator $translator = null): self - { - $group = self::$groups[$name] - ?? throw new \InvalidArgumentException("Unknown validation group [{$name}]."); - - return new self($data, $group['rules'], $group['messages'], $translator); - } - - /** Drop all custom rules + groups — test helper; never needed on the hot path. */ - public static function flushExtensions(): void - { - self::$extensions = []; - self::$extensionMessages = []; - self::$groups = []; - } - - public function fails(): bool - { - $this->run(); - return $this->errors !== []; - } - - public function passes(): bool - { - return !$this->fails(); - } - - /** @return array> */ - public function errors(): array - { - $this->run(); - return $this->errors; - } - - /** - * Validate and return only the validated fields; throws on failure. - * - * @return array - */ - public function validate(): array - { - if ($this->fails()) { - throw new ValidationException($this->errors); - } - - $validated = []; - foreach (array_keys($this->rules) as $field) { - if (array_key_exists($field, $this->data)) { - $validated[$field] = $this->data[$field]; - } - } - return $validated; - } - - private function run(): void - { - $this->errors = []; - - foreach ($this->rules as $field => $ruleSet) { - $rules = is_array($ruleSet) ? $ruleSet : explode('|', $ruleSet); - $value = $this->data[$field] ?? null; - $present = array_key_exists($field, $this->data) && $value !== '' && $value !== null; - - // nullable short-circuits all other rules when the field is absent/empty. - if (!$present && in_array('nullable', $rules, true)) { - continue; - } - - foreach ($rules as $rule) { - if ($rule === 'nullable' || $rule === '') { - continue; - } - [$name, $param] = array_pad(explode(':', $rule, 2), 2, null); - - if ($name !== 'required' && !$present) { - continue; // value rules skip absent fields; required handles presence - } - - $replace = $this->validateRule($name, $value, $param, $field); - if ($replace !== null) { - $this->addError($field, $name, $replace); - } - } - } - } - - /** - * Returns null when the rule passes, or an array of message replacements - * (the rule failed). - * - * @return array|null - */ - private function validateRule(string $rule, mixed $value, ?string $param, string $field): ?array - { - $ok = match ($rule) { - 'required' => $this->present($value), - 'string' => is_string($value), - 'integer' => filter_var($value, FILTER_VALIDATE_INT) !== false, - 'numeric' => is_numeric($value), - 'boolean' => in_array($value, [true, false, 0, 1, '0', '1', 'true', 'false'], true), - 'array' => is_array($value), - 'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false, - 'url' => filter_var($value, FILTER_VALIDATE_URL) !== false, - 'http_url' => self::isHttpUrl($value), - 'timezone' => is_string($value) && in_array($value, timezone_identifiers_list(), true), - 'enum' => self::isEnumValue($value, $param), - 'min' => $this->size($value) >= (float) $param, - 'max' => $this->size($value) <= (float) $param, - 'between' => $this->between($value, $param), - 'in' => in_array((string) $value, explode(',', (string) $param), true), - 'regex' => $param !== null && @preg_match($param, (string) $value) === 1, - 'same' => $value === ($this->data[$param] ?? null), - 'different' => $value !== ($this->data[$param] ?? null), - 'confirmed' => $value === ($this->data[$field . '_confirmation'] ?? null), - default => $this->runExtension($rule, $value, $param), - }; - - return $ok ? null : $this->replacements($rule, $param); - } - - /** @return array */ - private function replacements(string $rule, ?string $param): array - { - $replace = []; - switch ($rule) { - case 'min': - case 'max': - $replace[$rule] = (string) $param; - break; - case 'between': - [$a, $b] = array_pad(explode(',', (string) $param), 2, ''); - $replace['min'] = $a; - $replace['max'] = $b; - break; - case 'same': - case 'different': - $replace['other'] = (string) $param; - break; - case 'in': - $replace['values'] = (string) $param; - break; - } - return $replace; - } - - /** Dispatch a rule not known built-in to a registered custom rule (unknown → pass). */ - private function runExtension(string $rule, mixed $value, ?string $param): bool - { - $ext = self::$extensions[$rule] ?? null; - - // Unknown rule: pass rather than fail hard (a typo shouldn't 422 the world). - return $ext === null ? true : $ext($value, $param, $this->data); - } - - /** True when $value is a valid case of the (backed or pure) enum named in $param. */ - private static function isEnumValue(mixed $value, ?string $enum): bool - { - if ($enum === null || enum_exists($enum) === false) { - return false; - } - if (method_exists($enum, 'tryFrom')) { // backed enum - return (is_string($value) || is_int($value)) && $enum::tryFrom($value) !== null; - } - foreach ($enum::cases() as $case) { // pure enum → match name - if ($case->name === $value) { - return true; - } - } - return false; - } - - /** True for a syntactically valid absolute http/https URL. */ - private static function isHttpUrl(mixed $value): bool - { - if (!is_string($value) || filter_var($value, FILTER_VALIDATE_URL) === false) { - return false; - } - $scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME)); - - return $scheme === 'http' || $scheme === 'https'; - } - - private function present(mixed $value): bool - { - if (is_array($value)) { - return $value !== []; - } - return $value !== null && $value !== ''; - } - - private function size(mixed $value): float - { - if (is_numeric($value)) { - return (float) $value; - } - if (is_array($value)) { - return (float) count($value); - } - return (float) mb_strlen((string) $value); - } - - private function between(mixed $value, ?string $param): bool - { - [$a, $b] = array_pad(explode(',', (string) $param), 2, null); - $n = $this->size($value); - return $n >= (float) $a && $n <= (float) $b; - } - - /** @param array $replace */ - private function addError(string $field, string $rule, array $replace): void - { - $replace['field'] = $field; - $this->errors[$field][] = $this->message($field, $rule, $replace); - } - - /** @param array $replace */ - private function message(string $field, string $rule, array $replace): string - { - $custom = $this->messages["{$field}.{$rule}"] ?? null; - if ($custom !== null) { - return $this->interpolate($custom, $replace); - } - - if ($this->translator !== null && $this->translator->has("validation.{$rule}")) { - return $this->translator->get("validation.{$rule}", $replace); - } - - $default = self::DEFAULTS[$rule] - ?? self::$extensionMessages[$rule] - ?? "The {$field} field is invalid."; - - return $this->interpolate($default, $replace); - } - - /** @param array $replace */ - private function interpolate(string $line, array $replace): string - { - foreach ($replace as $key => $value) { - $line = str_replace(':' . $key, (string) $value, $line); - } - return $line; - } -} diff --git a/plugins/Validation/config/validation.php b/plugins/Validation/config/validation.php deleted file mode 100644 index c08872e..0000000 --- a/plugins/Validation/config/validation.php +++ /dev/null @@ -1,39 +0,0 @@ - [ - CommonRules::class, - FinancialRules::class, - ], - - // CI rule groups — name => ['rules' => [...], 'messages' => [...]]. - 'groups' => [ - // 'login' => [ - // 'rules' => [ - // 'email' => 'required|email', - // 'password' => 'required|string|min:8', - // ], - // 'messages' => [ - // 'email.required' => 'We need your email to sign you in.', - // ], - // ], - ], -]; diff --git a/plugins/Validation/module.json b/plugins/Validation/module.json deleted file mode 100644 index eafcc9f..0000000 --- a/plugins/Validation/module.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "validation", - "version": "1.0.0", - "solves": "validation.rules", - "type": "module", - - "requires": [], - "exposes": [], - - "routes": [], - "emits": [], - "listens": [], - - "documentation": "The Validation plugin — a shared, dependency-free request-validation engine (Plugins\\Validation\\Validator) that produces the kernel's standard 422 ValidationException. DTOs extend Plugins\\Validation\\AbstractDto and declare rules() instead of hand-rolling error accumulation. Rules use Laravel-style strings ('required|email|max:150'); shape lives in rules, deep domain invariants stay in value objects. Extend with single closures (Validator::extend) or CodeIgniter-style rule-set CLASSES (Validator::extendWith) and reusable named rule GROUPS (Validator::group), all configured once at boot from config/validation.php. The Provider only loads that config; the Validator is used statically at the DTO boundary (no container, no per-request cost).", - - "config": [] -} diff --git a/plugins/View/API/Contracts/ViewDecoratorContract.php b/plugins/View/API/Contracts/ViewDecoratorContract.php deleted file mode 100644 index b69daeb..0000000 --- a/plugins/View/API/Contracts/ViewDecoratorContract.php +++ /dev/null @@ -1,15 +0,0 @@ -|null $options Engine options (e.g. ['layout' => 'layouts/app']). - * @param bool|null $saveData Persist set data for subsequent calls. - */ - public function render(string $view, ?array $options = null, ?bool $saveData = null): string; - - /** - * Render a raw template string into HTML. - * - * @param array|null $options - */ - public function renderString(string $view, ?array $options = null, ?bool $saveData = null): string; - - /** - * Set several pieces of view data at once. - * - * @param array $data - * @param null|'html'|'raw' $context When 'html', string values are escaped. - */ - public function setData(array $data = [], ?string $context = null): static; - - /** - * Set a single piece of view data. - * - * Escaping: pass $context = 'html' to pre-escape here AND echo the value raw - * in the template, OR pass it raw (no context) and escape in the template. - * Do NOT do both — that double-escapes. - * - * @param null|'html'|'raw' $context When 'html', string values are escaped. - */ - public function setVar(string $name, mixed $value = null, ?string $context = null): static; - - /** - * Remove all view data. - */ - public function resetData(): static; -} diff --git a/plugins/View/Exceptions/ViewException.php b/plugins/View/Exceptions/ViewException.php deleted file mode 100644 index dd37a6d..0000000 --- a/plugins/View/Exceptions/ViewException.php +++ /dev/null @@ -1,30 +0,0 @@ - */ - private array $data = []; - - /** @var array|null */ - private ?array $tempData = null; - - /** @var array */ - private array $renderVars = []; - - /** @var array */ - private array $performanceData = []; - - private ?string $layout = null; - - /** @var array> */ - private array $sections = []; - - /** @var list */ - private array $sectionStack = []; - - /** - * @param list $viewPaths Global cascade — absolute dirs searched in order - * (project-first, then plugin fallbacks) for plain names. - * @param list $extensions Recognised file extensions (default ['php']). - * @param list> $decorators Output decorators, applied in order. - * @param (callable(string):string)|null $escaper HTML escaper; defaults to htmlspecialchars. - * @param array> $namespaces Map of "namespace" => absolute dirs, for - * targeted `namespace::view` resolution. - */ - public function __construct( - private readonly array $viewPaths, - private readonly array $extensions = ['php'], - private readonly array $decorators = [], - private readonly bool $saveData = false, - ?callable $escaper = null, - private readonly array $namespaces = [], - ) { - $this->escaper = $escaper ?? static fn (string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - } - - /** @var callable(string):string */ - private $escaper; - - public function render(string $view, ?array $options = null, ?bool $saveData = null): string - { - $start = microtime(true); - $saveData ??= $this->saveData; - - $this->setupRenderVars($view, $options); - $this->resolveViewFile(); - $this->prepareTemplateData($saveData); - - if (! empty($this->renderVars['options']['layout'])) { - $viewOutput = $this->executeView(); - - $layout = $this->renderVars['options']['layout']; - $this->setupRenderVars($layout, $options); - $this->resolveViewFile(); - - $this->tempData['view'] = $viewOutput; - $output = $this->executeView(); - } else { - $output = $this->executeView(); - $output = $this->handleLayout($output, $options, $saveData); - } - - $output = $this->decorateOutput($output); - - $this->logPerformance($start, microtime(true), $this->renderVars['view']); - $this->tempData = null; - - return $output; - } - - public function renderString(string $view, ?array $options = null, ?bool $saveData = null): string - { - $start = microtime(true); - $saveData ??= $this->saveData; - $this->prepareTemplateData($saveData); - - $output = (function (string $view): string { - extract($this->tempData); - ob_start(); - eval('?>' . $view); - - return ob_get_clean() ?: ''; - })($view); - - $this->logPerformance($start, microtime(true), $this->excerpt($view)); - $this->tempData = null; - - return $output; - } - - public function setData(array $data = [], ?string $context = null): static - { - if ($context !== null && $context !== 'raw') { - foreach ($data as $key => $value) { - $data[$key] = $this->escapeViewData($value); - } - } - - $this->tempData ??= $this->data; - $this->tempData = array_merge($this->tempData, $data); - - return $this; - } - - public function setVar(string $name, mixed $value = null, ?string $context = null): static - { - if ($context !== null && $context !== 'raw') { - $value = $this->escapeViewData($value); - } - - $this->tempData ??= $this->data; - $this->tempData[$name] = $value; - - return $this; - } - - public function resetData(): static - { - $this->data = []; - - return $this; - } - - /** @return array */ - public function getData(): array - { - return $this->tempData ?? $this->data; - } - - public function extend(string $layout): void - { - $this->layout = $layout; - } - - public function section(string $name): void - { - $this->sectionStack[] = $name; - ob_start(); - } - - public function endSection(): void - { - $contents = ob_get_clean(); - - if ($this->sectionStack === []) { - throw new \RuntimeException('View: no current section to end.'); - } - - $section = array_pop($this->sectionStack); - $this->sections[$section] ??= []; - $this->sections[$section][] = $contents; - } - - public function renderSection(string $sectionName, bool $saveData = false): void - { - if (! isset($this->sections[$sectionName])) { - return; - } - - foreach ($this->sections[$sectionName] as $key => $contents) { - echo $contents; - if (! $saveData) { - unset($this->sections[$sectionName][$key]); - } - } - } - - public function include(string $view, ?array $options = null, bool $saveData = true): string - { - return $this->render($view, $options, $saveData); - } - - /** @return array */ - public function getPerformanceData(): array - { - return $this->performanceData; - } - - public function excerpt(string $string, int $length = 20): string - { - return (strlen($string) > $length) ? substr($string, 0, $length - 3) . '...' : $string; - } - - // ── helpers ────────────────────────────────────────────────────────── - - private function setupRenderVars(string $view, ?array $options): void - { - $fileExt = pathinfo($view, PATHINFO_EXTENSION); - - $this->renderVars['view'] = $fileExt === '' ? $view . '.php' : $view; - - if (! in_array($fileExt, $this->extensions, true)) { - $this->renderVars['view'] = str_replace('.', DIRECTORY_SEPARATOR, $view) . '.php'; - } - - $this->renderVars['options'] = $options ?? []; - $this->renderVars['start'] = microtime(true); - } - - private function resolveViewFile(): void - { - $this->renderVars['file'] = false; - $view = $this->renderVars['view']; - - $viewIsAbsolute = str_starts_with($view, DIRECTORY_SEPARATOR); - if ($viewIsAbsolute && is_file($view)) { - $this->renderVars['file'] = $view; - - return; - } - - foreach ($this->candidateDirs($view, $relative) as $dir) { - $fullPath = realpath(rtrim($dir, '/') . DIRECTORY_SEPARATOR . ltrim($relative, '/')); - if ($fullPath && is_file($fullPath)) { - $this->renderVars['file'] = $fullPath; - - return; - } - } - - throw ViewException::forInvalidFile($view); - } - - /** - * Build the ordered list of directories to search for $view, honouring the - * deterministic priority model: - * - * - "namespace::view" → the PROJECT's override of that namespace first - * (each global path + "{namespace}/"), THEN the namespace's own dirs. - * This lets a project override a plugin view it targets by name while - * the plugin stays the canonical source. - * - plain "view" → the global cascade (project-first, plugin - * fallbacks) exactly as ordered by the compiled view manifest. - * - * $relative is set by-reference to the path fragment under each dir. - * - * @param-out string $relative - * @return list - */ - private function candidateDirs(string $view, ?string &$relative): array - { - if (str_contains($view, '::')) { - [$namespace, $relative] = explode('::', $view, 2); - - $dirs = []; - // Project override: drop "{namespace}/welcome.php" into any global - // (project-first) path to override the plugin's own view. - foreach ($this->viewPaths as $path) { - $dirs[] = rtrim($path, '/') . DIRECTORY_SEPARATOR . $namespace; - } - // The namespace's registered source dirs (the plugin itself). - foreach ($this->namespaces[$namespace] ?? [] as $nsPath) { - $dirs[] = $nsPath; - } - - return $dirs; - } - - $relative = $view; - - return $this->viewPaths; - } - - private function executeView(): string - { - $renderVars = $this->renderVars; - - $output = (function (): string { - extract($this->tempData); - ob_start(); - require $this->renderVars['file']; - - return ob_get_clean() ?: ''; - })(); - - $this->renderVars = $renderVars; - - return $output; - } - - private function handleLayout(string $output, ?array $options, bool $saveData): string - { - if ($this->layout !== null && $this->sectionStack === []) { - $layoutView = $this->layout; - $this->layout = null; - $renderVars = $this->renderVars; - $output = $this->render($layoutView, $options, $saveData); - $this->renderVars = $renderVars; - } - - return $output; - } - - private function logPerformance(float $start, float $end, string $view): void - { - $this->performanceData[] = [ - 'start' => $start, - 'end' => $end, - 'view' => $view, - ]; - } - - private function prepareTemplateData(bool $saveData): void - { - $this->tempData ??= $this->data; - - if ($saveData) { - $this->data = $this->tempData; - } - } - - private function escapeViewData(mixed $value): mixed - { - if (is_array($value)) { - foreach ($value as $key => $item) { - $value[$key] = $this->escapeViewData($item); - } - - return $value; - } - - if (is_string($value)) { - return ($this->escaper)($value); - } - - return $value; - } - - private function decorateOutput(string $html): string - { - foreach ($this->decorators as $decorator) { - if (! is_subclass_of($decorator, ViewDecoratorContract::class)) { - throw ViewException::forInvalidDecorator($decorator); - } - - $html = $decorator::decorate($html); - } - - return $html; - } -} diff --git a/plugins/View/Infrastructure/SidebarManager.php b/plugins/View/Infrastructure/SidebarManager.php deleted file mode 100644 index 8def041..0000000 --- a/plugins/View/Infrastructure/SidebarManager.php +++ /dev/null @@ -1,296 +0,0 @@ ->,visible:bool,order:int,permission:?string}> */ - private array $sections = []; - - private string $currentPath; - - /** @var array hash set for O(1) permission lookup */ - private array $permissions = []; - - /** @var array|null icon cache (instance-scoped — Swoole safe) */ - private ?array $iconCache = null; - - private ?string $renderedCache = null; - - private bool $isDirty = true; - - /** - * @param list $permissions - */ - public function __construct(string $currentRoute = '', array $permissions = []) - { - $this->currentPath = $currentRoute ? (string) parse_url($currentRoute, PHP_URL_PATH) : ''; - $this->permissions = array_flip($permissions); - } - - /** - * @param array $options - */ - public function addSection(string $key, string $title, array $options = []): self - { - $this->sections[$key] = [ - 'title' => $title, - 'items' => [], - 'visible' => $options['visible'] ?? true, - 'order' => $options['order'] ?? 100, - 'permission' => $options['permission'] ?? null, - ]; - - $this->isDirty = true; - - return $this; - } - - /** - * @param array $item - */ - public function addItem(string $sectionKey, array $item): self - { - if (! isset($this->sections[$sectionKey])) { - $this->addSection($sectionKey, $sectionKey); - } - - $this->sections[$sectionKey]['items'][] = [ - 'id' => $item['id'] ?? uniqid('nav_', true), - 'label' => $item['label'], - 'url' => $item['url'], - 'icon' => $item['icon'] ?? null, - 'badge' => $item['badge'] ?? null, - 'badge_type' => $item['badge_type'] ?? 'default', - 'counter' => $item['counter'] ?? null, - 'visible' => $item['visible'] ?? true, - 'permission' => $item['permission'] ?? null, - 'children' => $item['children'] ?? [], - 'order' => $item['order'] ?? 100, - ]; - - $this->isDirty = true; - - return $this; - } - - public function updateBadge(string $itemId, mixed $badge, string $type = 'default'): self - { - foreach ($this->sections as &$section) { - foreach ($section['items'] as &$item) { - if ($item['id'] === $itemId) { - $item['badge'] = $badge; - $item['badge_type'] = $type; - $this->isDirty = true; - break 2; - } - } - } - - return $this; - } - - public function updateCounter(string $itemId, ?int $counter): self - { - foreach ($this->sections as &$section) { - foreach ($section['items'] as &$item) { - if ($item['id'] === $itemId) { - $item['counter'] = $counter; - $this->isDirty = true; - break 2; - } - } - } - - return $this; - } - - public function removeItem(string $itemId): self - { - foreach ($this->sections as &$section) { - $section['items'] = array_values(array_filter( - $section['items'], - static fn ($item) => $item['id'] !== $itemId, - )); - } - - $this->isDirty = true; - - return $this; - } - - /** - * @param array $item - */ - public function isActive(array $item): bool - { - $itemPath = parse_url($item['url'], PHP_URL_PATH); - - if ($itemPath === $this->currentPath) { - return true; - } - - return $itemPath !== '/' && is_string($itemPath) && str_starts_with($this->currentPath, $itemPath); - } - - private function hasPermission(?string $permission): bool - { - if ($permission === null) { - return true; - } - - return isset($this->permissions[$permission]) || isset($this->permissions['admin']); - } - - public function render(bool $forceRender = false): string - { - if (! $forceRender && ! $this->isDirty && $this->renderedCache !== null) { - return $this->renderedCache; - } - - $buffer = []; - - uasort($this->sections, static fn ($a, $b) => $a['order'] <=> $b['order']); - - foreach ($this->sections as $section) { - if (! $section['visible'] || ! $this->hasPermission($section['permission'])) { - continue; - } - - usort($section['items'], static fn ($a, $b) => $a['order'] <=> $b['order']); - - $buffer[] = '
'; - $buffer[] = '

'; - $buffer[] = htmlspecialchars($section['title'], ENT_QUOTES, 'UTF-8'); - $buffer[] = '

'; - - foreach ($section['items'] as $item) { - if (! $item['visible'] || ! $this->hasPermission($item['permission'])) { - continue; - } - - $buffer[] = $this->renderItem($item); - } - - $buffer[] = '
'; - } - - $this->renderedCache = implode('', $buffer); - $this->isDirty = false; - - return $this->renderedCache; - } - - /** - * @param array $item - */ - private function renderItem(array $item): string - { - $isActive = $this->isActive($item); - - $buffer = []; - $buffer[] = ''; - - if ($item['icon']) { - $buffer[] = $this->renderIcon($item['icon']); - } - - $buffer[] = ''; - $buffer[] = htmlspecialchars($item['label'], ENT_QUOTES, 'UTF-8'); - $buffer[] = ''; - - $buffer[] = ''; - $buffer[] = htmlspecialchars($item['label'], ENT_QUOTES, 'UTF-8'); - $buffer[] = ''; - - if ($item['badge'] !== null) { - $buffer[] = ''; - $buffer[] = htmlspecialchars((string) $item['badge'], ENT_QUOTES, 'UTF-8'); - $buffer[] = ''; - } elseif ($item['counter'] !== null) { - $buffer[] = ''; - $buffer[] = htmlspecialchars((string) $item['counter'], ENT_QUOTES, 'UTF-8'); - $buffer[] = ''; - } - - $buffer[] = ''; - - if (! empty($item['children']) && $isActive) { - $buffer[] = ''; - } - - return implode('', $buffer); - } - - private function renderIcon(string $iconName): string - { - $this->iconCache ??= $this->getIconPaths(); - - $svgPath = $this->iconCache[$iconName] ?? $iconName; - - return '' . $svgPath . ''; - } - - /** @return array */ - private function getIconPaths(): array - { - return [ - 'dashboard' => '', - 'calendar' => '', - 'grid' => '', - 'users' => '', - 'vote' => '', - 'lightning' => '', - 'money' => '', - 'chart' => '', - 'settings' => '', - 'back' => '', - ]; - } - - public function toJson(): string - { - return (string) json_encode($this->sections, JSON_PRETTY_PRINT); - } - - /** @return array */ - public function getSections(): array - { - return $this->sections; - } - - /** @return array|null */ - public function getSection(string $key): ?array - { - return $this->sections[$key] ?? null; - } - - public function clear(): self - { - $this->sections = []; - - return $this; - } -} diff --git a/plugins/View/Provider.php b/plugins/View/Provider.php deleted file mode 100644 index 774affe..0000000 --- a/plugins/View/Provider.php +++ /dev/null @@ -1,136 +0,0 @@ - */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [ViewRendererContract::class]; - } - - public function register(ModuleContainer $container): void - { - if ($container->has(ViewRendererContract::class)) { - return; // a project already provided a renderer - } - - $container->bind(ViewRendererContract::class, static function (): PhpViewRenderer { - // The compiled view manifest is the single source of truth for the - // deterministic priority cascade: project view paths first, plugin - // paths after, plus the namespace map for `plugin::view` lookups. - // (Built by CompileViewManifestStage at boot — see that stage.) - $manifest = self::loadViewManifest(); - - $paths = $manifest['global']; - - // Optional env override/augmentation. VIEW_PATHS is PREPENDED so an - // operator can inject an even-higher-priority project path at runtime - // without ever letting a plugin outrank the project implicitly. - $envPaths = array_values(array_filter(array_map(static function (string $path): ?string { - if (str_starts_with($path, '/') || preg_match('/^[A-Za-z]:\\\\/', $path) === 1) { - return is_dir($path) ? rtrim($path, '/') : null; - } - $projectPath = Paths::project($path); - return is_dir($projectPath) ? $projectPath : null; - }, self::splitList(env('VIEW_PATHS') ?: '')))); - - $paths = array_values(array_unique(array_merge($envPaths, $paths))); - - if ($paths === [] && $manifest['namespaces'] === []) { - // Last-resort default so a project with no declared views still works. - $default = Paths::project('resources/views'); - if (is_dir($default)) { - $paths[] = $default; - } - } - - return new PhpViewRenderer( - viewPaths: $paths, - extensions: self::splitList(env('VIEW_EXTENSIONS') ?: 'php') ?: ['php'], - decorators: [], - saveData: filter_var(env('VIEW_SAVE_DATA') ?: 'false', FILTER_VALIDATE_BOOLEAN), - namespaces: $manifest['namespaces'], - ); - }); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - } - - /** - * Load the compiled view manifest (project-first global cascade + namespace - * map). Returns an empty, well-shaped structure when the manifest has not - * been compiled yet, so the renderer degrades gracefully. - * - * @return array{global: list, namespaces: array>} - */ - private static function loadViewManifest(): array - { - $path = Paths::cache('manifests/view-manifest.php'); - $data = is_file($path) ? require $path : null; - - return [ - 'global' => is_array($data['global'] ?? null) ? array_values($data['global']) : [], - 'namespaces' => is_array($data['namespaces'] ?? null) ? $data['namespaces'] : [], - ]; - } - - /** - * Split a colon/comma-separated config string into a trimmed list. - * - * @return list - */ - private static function splitList(string $value): array - { - if ($value === '') { - return []; - } - - return array_values(array_filter( - array_map('trim', preg_split('/[:,]/', $value) ?: []), - static fn(string $item): bool => $item !== '', - )); - } -} diff --git a/plugins/View/module.json b/plugins/View/module.json deleted file mode 100644 index 29539da..0000000 --- a/plugins/View/module.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "view", - "version": "1.0.0", - "solves": "view.rendering", - "type": "module", - - "requires": [], - "exposes": ["Plugins\\View\\API\\Contracts\\ViewRendererContract"], - - "routes": [], - "emits": [], - "listens": [], - - "config": [ - { "key": "VIEW_PATHS", "type": "string", "required": false }, - { "key": "VIEW_EXTENSIONS", "type": "string", "required": false }, - { "key": "VIEW_SAVE_DATA", "type": "bool", "required": false } - ] -} diff --git a/plugins/ViteManifest/API/Contracts/ViteContract.php b/plugins/ViteManifest/API/Contracts/ViteContract.php deleted file mode 100644 index a7ecbc8..0000000 --- a/plugins/ViteManifest/API/Contracts/ViteContract.php +++ /dev/null @@ -1,44 +0,0 @@ --hot` file exists) it points tags at the running Vite dev - * server; in PROD it reads `manifest-.json` and emits hashed, - * preload-optimised tags. - */ -interface ViteContract -{ - /** - * Render - HTML, - implode(' ', $attrs), - $runtime, - )); - } - - public function manifestHash(): ?string - { - if ($this->isRunningHot()) { - return null; - } - $path = $this->config->manifestPath(); - return is_file($path) ? (md5_file($path) ?: null) : null; - } - - public function __toString(): string - { - return $this->render($this->entryPoints)->__toString(); - } - - // ── preload assembly ───────────────────────────────────────────────────── - - /** - * @param list $preloads - * @return list - */ - private function buildPreloads(array $preloads): array - { - // De-dup by URL, CSS first (higher priority). - $unique = []; - foreach ($preloads as $args) { - $unique[$args[1]] = $args; - } - uasort($unique, fn($a, $b) => (int) $this->isCssPath($b[1]) <=> (int) $this->isCssPath($a[1])); - - $out = []; - foreach ($unique as $args) { - $tag = $this->makePreloadTagForChunk(...$args); - if ($tag !== '') { - $out[] = $tag; - } - } - return $out; - } - - /** - * @param array $manifest - * @return array{0:string,1:array} - */ - private function findChunkByFile(array $manifest, string $file): array - { - foreach ($manifest as $key => $chunk) { - if (is_array($chunk) && ($chunk['file'] ?? null) === $file) { - return [$key, $chunk]; - } - } - return [$file, ['file' => $file]]; - } - - // ── tag builders ───────────────────────────────────────────────────────── - - private function makeTagForChunk(string $src, string $url, ?array $chunk, ?array $manifest): string - { - if ( - $this->config->nonce === null - && $this->config->integrityKey !== false - && !array_key_exists((string) $this->config->integrityKey, $chunk ?? []) - && $this->scriptAttributeResolvers === [] - && $this->styleAttributeResolvers === [] - ) { - return $this->isCssPath($url) - ? $this->makeStylesheetTag($url, []) - : $this->makeScriptTag($url, []); - } - - return $this->isCssPath($url) - ? $this->makeStylesheetTag($url, $this->resolveStyleAttributes($src, $url, $chunk, $manifest)) - : $this->makeScriptTag($url, $this->resolveScriptAttributes($src, $url, $chunk, $manifest)); - } - - private function makePreloadTagForChunk(string $src, string $url, array $chunk, array $manifest): string - { - $attributes = $this->resolvePreloadAttributes($src, $url, $chunk, $manifest); - if ($attributes === false) { - return ''; - } - $stored = $attributes; - unset($stored['href']); - $this->preloadedAssets[$url] = $this->parseAttributes($stored); - - return 'parseAttributes($attributes)) . ' />'; - } - - private function makeScriptTag(string $url, array $attributes): string - { - $attrs = $this->parseAttributes(array_merge([ - 'type' => 'module', - 'src' => $url, - 'nonce' => $this->config->nonce ?? false, - ], $attributes)); - return ''; - } - - private function makeStylesheetTag(string $url, array $attributes): string - { - $attrs = $this->parseAttributes(array_merge([ - 'rel' => 'stylesheet', - 'href' => $url, - 'nonce' => $this->config->nonce ?? false, - ], $attributes)); - return ''; - } - - // ── attribute resolution ───────────────────────────────────────────────── - - private function resolveScriptAttributes(string $src, string $url, ?array $chunk, ?array $manifest): array - { - $attributes = $this->integrityAttributes($chunk); - foreach ($this->scriptAttributeResolvers as $resolver) { - $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest)); - } - return $attributes; - } - - private function resolveStyleAttributes(string $src, string $url, ?array $chunk, ?array $manifest): array - { - $attributes = $this->integrityAttributes($chunk); - foreach ($this->styleAttributeResolvers as $resolver) { - $attributes = array_merge($attributes, $resolver($src, $url, $chunk, $manifest)); - } - return $attributes; - } - - private function resolvePreloadAttributes(string $src, string $url, array $chunk, array $manifest): array|false - { - $attributes = $this->isCssPath($url) ? [ - 'rel' => 'preload', - 'as' => 'style', - 'href' => $url, - 'nonce' => $this->config->nonce ?? false, - 'crossorigin' => $this->resolveStyleAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false, - ] : [ - 'rel' => 'modulepreload', - 'href' => $url, - 'nonce' => $this->config->nonce ?? false, - 'crossorigin' => $this->resolveScriptAttributes($src, $url, $chunk, $manifest)['crossorigin'] ?? false, - ]; - - $attributes = array_merge($attributes, $this->integrityAttributes($chunk)); - - foreach ($this->preloadAttributeResolvers as $resolver) { - $resolved = $resolver($src, $url, $chunk, $manifest); - if ($resolved === false) { - return false; - } - $attributes = array_merge($attributes, $resolved); - } - return $attributes; - } - - private function integrityAttributes(?array $chunk): array - { - $key = $this->config->integrityKey; - if ($key === false) { - return []; - } - return ['integrity' => $chunk[$key] ?? false]; - } - - // ── helpers ────────────────────────────────────────────────────────────── - - /** Build a public URL for a build-relative path, honouring an optional CDN base. */ - private function assetUrl(string $path): string - { - $base = rtrim($this->config->assetBase, '/'); - return $base . '/' . ltrim($path, '/'); - } - - private function isCssPath(string $path): bool - { - return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)(\?.*)?$/', $path) === 1; - } - - /** - * @param array $attributes - * @return list - */ - private function parseAttributes(array $attributes): array - { - $out = []; - foreach ($attributes as $key => $value) { - if ($value === false || $value === null) { - continue; - } - if ($value === true) { - $out[] = is_int($key) ? '' : $key; - } elseif (is_int($key)) { - $out[] = (string) $value; - } else { - $out[] = $key . '="' . $value . '"'; - } - } - return array_values(array_filter($out, static fn($v) => $v !== '')); - } -} diff --git a/plugins/ViteManifest/Provider.php b/plugins/ViteManifest/Provider.php deleted file mode 100644 index 9a5aa27..0000000 --- a/plugins/ViteManifest/Provider.php +++ /dev/null @@ -1,64 +0,0 @@ -/ tags, - * HMR dev-server injection, preloading, React refresh) for HTML routes. - * - * The GDA-clean successor to the old Laravel `Vite` transplant: no framework - * globals, config injected from env, surfaces (`manifest-.json` + - * `-hot`) matching the `hkmPlugin` vite output. - * - * ACTIVATION: on-demand. A route that renders a shell needing built assets - * declares it: { "requires": ["vite.manifest"] } - * - * Inside a view use the helpers (Support/helpers.php, wired by `hkm plugins - * enable`): `vite('src/surfaces/admin/index.tsx', 'admin')`, `vite_asset(...)`, - * `vite_react_refresh('admin')`. In a controller inject {@see ViteContract}. - */ -final class Provider implements ModuleContract -{ - public function solves(): string - { - return 'vite.manifest'; - } - - /** @return list */ - public function requires(): array - { - return []; - } - - /** @return list */ - public function exposes(): array - { - return [ViteContract::class]; - } - - public function register(ModuleContainer $container): void - { - if ($container->has(ViteContract::class)) { - return; // a project supplied its own resolver - } - - // Stateless config (paths are deploy-time) → a per-request singleton is - // cheap and OpenSwoole-safe; the manifest cache lives in ManifestReader. - $container->singleton(ViteContract::class, static fn(): ViteContract => ViteFactory::fromEnv()); - } - - public function boot(HttpPipeline $http, CliPipeline $cli, WorkerPipeline $worker, EventBus $events): void - { - // Nothing to hook — asset resolution is pull-based from views/controllers. - } -} diff --git a/plugins/ViteManifest/README.md b/plugins/ViteManifest/README.md deleted file mode 100644 index 7b4aede..0000000 --- a/plugins/ViteManifest/README.md +++ /dev/null @@ -1,83 +0,0 @@ -# ViteManifest (`Plugins\ViteManifest`, solves `vite.manifest`) - -Resolves Vite build assets for HTML routes — hashed ` +$graph->toArray(); // raw JSON-LD structure +$graph->toSchema(); // SiteSEO Schema instance (single node stays flat) +``` + +Node builders: + +| Method | Emits | +|---|---| +| `organization($name, $logo, $sameAs)` | `Organization` — publisher/author anchor | +| `website($name, $searchUrl)` | `WebSite` (+ `SearchAction` sitelinks searchbox) | +| `webPage($url, $name, $description)` | `WebPage` — the page node other nodes hang off | +| `breadcrumb($items)` | `BreadcrumbList` | +| `article(…)` / `newsArticle(…)` / `blogPosting(…)` | `Article` and its subtypes, wired to page + org + author | +| `product($url, $name, …, $offer, $rating, $review)` | `Product` + `Offer` + `AggregateRating` + `Review` | +| `book(…)`, `course(…)`, `realEstate(…)` | `Book`, `Course`, real-estate listing | +| `pageantEdition(…)`, `awardEdition(…)`, `contestant(…)` | `Event` + `Person` performers (contestants / nominees) | +| `faq($qa)` | `FAQPage` from a `question => answer` map | +| `node($type, $data)` | any node type there is no helper for | + +Every builder returns `$this`, so a page is one fluent chain. + +--- + +## `SeoHead` — the whole `` in one call + +```php +use Project\Support\Seo\SeoHead; + +echo SeoHead::for('https://example.com') + ->title('Hello world — Example') + ->description('An introduction to the platform.') + ->canonical('/blog/hello') + ->robots(index: true, follow: true, maxImagePreview: 'large', maxSnippet: 160) + ->hreflang('fr', '/fr/blog/hello') + ->xDefault('/blog/hello') + ->openGraph($ogType) // Plugins\SiteSEO\Type (build it via InteractsWithSeo::openGraph()) + ->graph($graph) // RichGraph — rendered as JSON-LD inside the head + ->render(); +``` + +`noindex()` is the one-call shortcut for `robots(index: false, follow: false)` — +use it on staging, private, and thin pages. `render()` and `__toString()` are +equivalent. + +--- + +## `IndexNowKey` + +```php +use Project\Support\Seo\IndexNowKey; + +$key = IndexNowKey::generate(); // 32 hex chars — publish once, keep stable +$key = IndexNowKey::fromString(env('INDEXNOW_KEY')); // validates ^[a-zA-Z0-9-]{8,128}$ + +$key->value(); // the key itself +$key->fileContents(); // body of the key file to serve +$key->path(); // '' → canonical "/{key}.txt" at the site root +$key->location('https://example.com'); // absolute keyLocation for the API call + +$key = $key->publishedAt('/seo/indexnow.txt'); // immutable — returns a NEW instance +``` + +Submission itself goes through the SiteSEO plugin +(`SeoServiceContract::indexNow(...)` / `indexNowChunks()` + the `seo.indexnow` +job), never from these classes. + +--- + +## Rules + +``` +✓ Sitemap/OG/JSON-LD building is DI-free — no module load, no container. +✓ Network actions (ping, IndexNow) need requires:["seo.management"] → HttpClientPort. +✓ Huge sitemaps → SitemapStreamWriter over a generator (keyset DB cursor), never an array/DOM. +✓ Every dynamic route gets a SitemapUrlProvider; assert uncoveredDynamicRoutes() is empty. +✓ One JSON-LD @graph per page (RichGraph), nodes linked by @id. +✗ Raw cURL for ping/IndexNow — always the SiteSEO gateway + HttpClientPort. +✗ Buffering a whole catalogue in memory to build a sitemap or submit IndexNow. +✗ Hardcoding the host — take the base URL from the request/DomainContext (InteractsWithSeo::siteBaseUrl()). +``` diff --git a/projects/Support/Seo/RouteCatalog.php b/projects/Support/Seo/RouteCatalog.php index 0cb2d6b..50ea462 100644 --- a/projects/Support/Seo/RouteCatalog.php +++ b/projects/Support/Seo/RouteCatalog.php @@ -4,6 +4,7 @@ namespace Project\Support\Seo; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteIndex; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; /** @@ -59,23 +60,38 @@ public static function fromManifest(?string $manifestPath = null): self /** * Public, static GET paths suitable for a sitemap (leading-slash paths). * + * DOMAIN GROUPS: a route may be grouped under a host, and a sitemap describes + * ONE host — so `$domain` selects which groups to enumerate. The default + * (null) returns only the shared, ungrouped routes, which is every route in a + * project that groups nothing and therefore leaves existing sitemaps + * byte-identical. Pass a host to get the groups that host matches (exact, + * wildcard or bare subdomain — see RouteIndex::hostCandidates) plus the shared + * ones, de-duplicated by path. + * * @param list $excludePrefixes Extra path prefixes to skip. * @param list $excludePaths Extra exact paths to skip. + * @param string|null $domain Host to enumerate, or null for shared only. * @return list */ - public function publicPaths(array $excludePrefixes = [], array $excludePaths = []): array + public function publicPaths(array $excludePrefixes = [], array $excludePaths = [], ?string $domain = null): array { $prefixes = [...self::DEFAULT_EXCLUDED_PREFIXES, ...$excludePrefixes]; $paths = [...self::DEFAULT_EXCLUDED_PATHS, ...$excludePaths]; + $wanted = $domain === null ? [] : RouteIndex::hostCandidates($domain); $found = []; foreach ($this->manifest as $key => $entry) { - [$method, $path] = array_pad(explode(' ', $key, 2), 2, ''); + $parsed = RouteIndex::parseKey($key); + $method = $parsed['method']; + $path = $parsed['path']; if (strtoupper($method) !== 'GET') { continue; } + if ($parsed['domain'] !== '' && !in_array($parsed['domain'], $wanted, true)) { + continue; // belongs to a different host — not this sitemap + } if ($path === '' || str_contains($path, '{')) { continue; // dynamic — cannot enumerate from the manifest } diff --git a/projects/projects.json b/projects/projects.json index 2cf9004..0967ef4 100644 --- a/projects/projects.json +++ b/projects/projects.json @@ -1,20 +1 @@ -{ - "shop": { - "name": "shop", - "version": "1.0.0", - "path": "/home/home/Documents/PROJECTS/psp-shop", - "domains": [ - "shop.com" - ] - }, - "hkmcode": { - "name": "hkmcode", - "version": "1.0.0", - "path": "/home/home/Documents/PROJECTS/hkmcode", - "domains": [ - "hkm.local", - "api.hkm.local", - "app.hkm.local" - ] - } -} +{} diff --git a/src/Commands/Migrate/CliCommandFactory.php b/src/Commands/Migrate/CliCommandFactory.php index 651eebc..8a5b6ad 100644 --- a/src/Commands/Migrate/CliCommandFactory.php +++ b/src/Commands/Migrate/CliCommandFactory.php @@ -61,7 +61,7 @@ public function all(): array return [ ...$this->migrate(), ...$this->generate(), - ...$this->tenant(), + // ...$this->tenant(), ...$this->seed(), ...$this->make(), ...$this->maintenance(), @@ -112,17 +112,17 @@ public function generate(): array * * @return list */ - public function tenant(): array - { - $c = $this->config; - return [ - new TenantMigrateRunCommand($c), - new TenantMigrateRollbackCommand($c), - new TenantMigrateResetCommand($c), - new TenantMigrateRefreshCommand($c), - new TenantMigrateStatusCommand($c), - ]; - } + // public function tenant(): array + // { + // $c = $this->config; + // return [ + // new TenantMigrateRunCommand($c), + // new TenantMigrateRollbackCommand($c), + // new TenantMigrateResetCommand($c), + // new TenantMigrateRefreshCommand($c), + // new TenantMigrateStatusCommand($c), + // ]; + // } /** * Seeder commands. diff --git a/src/Commands/Migrate/TenantCommand.php b/src/Commands/Migrate/TenantCommand.php deleted file mode 100644 index 21e7525..0000000 --- a/src/Commands/Migrate/TenantCommand.php +++ /dev/null @@ -1,167 +0,0 @@ - [__DIR__ . '/migrations'], - * 'tenants' => [ - * 'resolver' => new MyTenantResolver($pdo), - * ], - * ]; - * - * // Shape B — class name (default-constructible): - * return [ - * 'paths' => [__DIR__ . '/migrations'], - * 'tenants' => [ - * 'resolver_class' => MyTenantResolver::class, - * ], - * ]; - * - * Subclasses implement runForTenants(TenantAwareRunner $runner): int — they - * receive a wired runner and decide which method to call. - */ -abstract class TenantCommand extends LetMigrateCommand -{ - private ?TenantAwareRunner $cachedTenantRunner = null; - - /** - * Subclasses MUST register their own name/description and call this from - * configure() to pick up the standard tenant options. - */ - protected function registerTenantOptions(): void - { - $this->registerCommonOptions(); - $this->addOption('tenant', 't', - 'Tenant ID to operate on', - acceptsValue: true); - $this->addOption('all', '', - 'Operate on every registered tenant in sequence'); - } - - /** - * Build (and cache) the TenantAwareRunner from config. - * - * Validates that exactly one of --tenant / --all was supplied. Throws a - * clear runtime error if the config has no 'tenants' section or if the - * resolver can't be constructed. - */ - protected function tenantRunner(): TenantAwareRunner - { - if ($this->cachedTenantRunner !== null) { - return $this->cachedTenantRunner; - } - - $config = $this->loadConfig(); - $tenants = $config['tenants'] ?? null; - if (!is_array($tenants)) { - $this->error( - 'No tenant configuration. Add a "tenants" key to your config ' - . 'with either "resolver" (instance) or "resolver_class" (FQCN).', - ); - throw new \RuntimeException('Missing tenants config.'); - } - - $resolver = $this->buildResolver($tenants); - - // Strip the 'tenants' key from base config so it doesn't leak into - // per-tenant DriverRegistry::fromConfig() calls. - $baseConfig = array_diff_key($config, ['tenants' => 1]); - - return $this->cachedTenantRunner = new TenantAwareRunner( - resolver: $resolver, - baseConfig: $baseConfig, - logger: new NullLogger(), - ); - } - - /** - * @param array $tenants - */ - private function buildResolver(array $tenants): TenantResolverInterface - { - // Shape A — instance. - if (isset($tenants['resolver'])) { - $r = $tenants['resolver']; - if ($r instanceof TenantResolverInterface) { - return $r; - } - // Closure → call it. - if (is_callable($r)) { - $resolved = $r(); - if ($resolved instanceof TenantResolverInterface) { - return $resolved; - } - } - throw new \RuntimeException( - 'tenants.resolver did not produce a TenantResolverInterface.', - ); - } - - // Shape B — class name. - if (isset($tenants['resolver_class'])) { - $class = (string) $tenants['resolver_class']; - if (!class_exists($class)) { - throw new \RuntimeException( - "tenants.resolver_class '{$class}' does not exist.", - ); - } - $instance = new $class(); - if (!$instance instanceof TenantResolverInterface) { - throw new \RuntimeException( - "tenants.resolver_class '{$class}' must implement " - . 'AlfaCode\\LetMigrate\\Contract\\TenantResolverInterface.', - ); - } - return $instance; - } - - throw new \RuntimeException( - 'tenants.resolver or tenants.resolver_class is required.', - ); - } - - /** - * Convenience: validate that exactly one of --tenant or --all was given, - * and return the chosen tenant ID (or null when --all). - */ - protected function selectedTenant(): ?string - { - $tenant = $this->option('tenant'); - $all = $this->hasOption('all'); - - if ($tenant === null && !$all) { - $this->error('Specify exactly one of --tenant=ID or --all.'); - throw new \RuntimeException('Tenant target missing.'); - } - if ($tenant !== null && $all) { - $this->error('Cannot combine --tenant=ID with --all — pick one.'); - throw new \RuntimeException('Conflicting tenant flags.'); - } - - return $tenant !== null ? (string) $tenant : null; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateRefreshCommand.php b/src/Commands/Migrate/TenantMigrateRefreshCommand.php deleted file mode 100644 index ae853ab..0000000 --- a/src/Commands/Migrate/TenantMigrateRefreshCommand.php +++ /dev/null @@ -1,71 +0,0 @@ -name = 'tenant:refresh'; - $this->description = 'Reset and re-run all migrations across one or all tenants'; - $this->registerTenantOptions(); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - - if ($tenant !== null) { - $this->info("Refreshing tenant: {$tenant}"); - $result = $runner->refreshForTenant($tenant); - - if ($this->wantsJson()) { - $this->emitJson([ - 'tenant' => $tenant, - 'result' => (new JsonResultPresenter())->resultData($result), - ]); - return self::SUCCESS; - } - - $this->alertSuccess('Tenant refreshed', [ - "Tenant: {$tenant}", - "Applied: {$result->appliedCount()}", - ]); - return self::SUCCESS; - } - - $this->info('Refreshing ALL tenants…'); - $results = []; - foreach (array_keys($runner->statusForAllTenants()) as $id) { - $results[$id] = $runner->refreshForTenant($id); - } - - if ($this->wantsJson()) { - $payload = []; - foreach ($results as $id => $result) { - $payload[$id] = (new JsonResultPresenter())->resultData($result); - } - $this->emitJson(['tenants' => $payload]); - return self::SUCCESS; - } - - $rows = []; - foreach ($results as $id => $result) { - $rows[] = [$id, (string) $result->appliedCount()]; - } - $this->table() - ->headers(['Tenant', 'Applied']) - ->rows($rows) - ->render(); - $this->alertSuccess('All tenants refreshed', ['Tenants: ' . count($results)]); - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateResetCommand.php b/src/Commands/Migrate/TenantMigrateResetCommand.php deleted file mode 100644 index 1bfcc16..0000000 --- a/src/Commands/Migrate/TenantMigrateResetCommand.php +++ /dev/null @@ -1,63 +0,0 @@ -name = 'tenant:reset'; - $this->description = 'Reset (rollback all) migrations across one or all tenants — DESTRUCTIVE'; - $this->registerTenantOptions(); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - - if ($tenant !== null) { - $this->info("Resetting tenant: {$tenant}"); - $result = $runner->resetForTenant($tenant); - - if ($this->wantsJson()) { - $this->emitJson([ - 'tenant' => $tenant, - 'result' => (new JsonResultPresenter())->resultData($result), - ]); - return self::SUCCESS; - } - - $this->alertSuccess('Tenant reset', [ - "Tenant: {$tenant}", - "Rolled back: " . count((array) $result->rolledBack), - ]); - return self::SUCCESS; - } - - $this->info('Resetting ALL tenants…'); - $results = []; - foreach (array_keys($runner->statusForAllTenants()) as $id) { - $results[$id] = $runner->resetForTenant($id); - } - - if ($this->wantsJson()) { - $payload = []; - foreach ($results as $id => $result) { - $payload[$id] = (new JsonResultPresenter())->resultData($result); - } - $this->emitJson(['tenants' => $payload]); - return self::SUCCESS; - } - - $this->alertSuccess('All tenants reset', ['Tenants: ' . count($results)]); - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateRollbackCommand.php b/src/Commands/Migrate/TenantMigrateRollbackCommand.php deleted file mode 100644 index 5fb66d9..0000000 --- a/src/Commands/Migrate/TenantMigrateRollbackCommand.php +++ /dev/null @@ -1,80 +0,0 @@ -name = 'tenant:rollback'; - $this->description = 'Roll back the last N migration batches across one or all tenants'; - $this->registerTenantOptions(); - $this->addOption('steps', 's', - 'Number of batches to roll back', - acceptsValue: true, default: '1'); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - $steps = max(1, (int) $this->option('steps', '1')); - - if ($tenant !== null) { - $this->info("Rolling back tenant: {$tenant} (steps: {$steps})"); - $result = $runner->rollbackForTenant($tenant, $steps); - - if ($this->wantsJson()) { - $this->emitJson([ - 'tenant' => $tenant, - 'steps' => $steps, - 'result' => (new JsonResultPresenter())->resultData($result), - ]); - return self::SUCCESS; - } - - $this->alertSuccess('Rollback complete', [ - "Tenant: {$tenant}", - "Rolled back: " . count((array) $result->rolledBack), - ]); - return self::SUCCESS; - } - - // --all - $this->info("Rolling back ALL tenants (steps: {$steps})…"); - $results = []; - foreach (array_keys($this->tenantRunner()->statusForAllTenants()) as $id) { - $results[$id] = $runner->rollbackForTenant($id, $steps); - } - - if ($this->wantsJson()) { - $payload = []; - foreach ($results as $id => $result) { - $payload[$id] = (new JsonResultPresenter())->resultData($result); - } - $this->emitJson(['tenants' => $payload, 'steps' => $steps]); - return self::SUCCESS; - } - - $rows = []; - foreach ($results as $id => $result) { - $rows[] = [$id, (string) count((array) $result->rolledBack)]; - } - $this->table() - ->headers(['Tenant', 'Rolled back']) - ->rows($rows) - ->render(); - $this->alertSuccess('All tenants rolled back', [ - 'Tenants: ' . count($results), - 'Steps: ' . $steps, - ]); - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateRunCommand.php b/src/Commands/Migrate/TenantMigrateRunCommand.php deleted file mode 100644 index d4afffc..0000000 --- a/src/Commands/Migrate/TenantMigrateRunCommand.php +++ /dev/null @@ -1,73 +0,0 @@ -name = 'tenant:migrate'; - $this->description = 'Run migrations across one or all tenants'; - $this->registerTenantOptions(); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - - if ($tenant !== null) { - $this->info("Migrating tenant: {$tenant}"); - $result = $runner->runForTenant($tenant); - - if ($this->wantsJson()) { - $this->emitJson([ - 'tenant' => $tenant, - 'result' => (new JsonResultPresenter())->resultData($result), - ]); - return self::SUCCESS; - } - - $this->alertSuccess('Tenant migrated', [ - "Tenant: {$tenant}", - "Applied: {$result->appliedCount()}", - "Batch: {$result->batch}", - ]); - return self::SUCCESS; - } - - // --all - $this->info('Migrating ALL tenants…'); - $results = $runner->runForAllTenants(); - - if ($this->wantsJson()) { - $payload = []; - foreach ($results as $id => $result) { - $payload[$id] = (new JsonResultPresenter())->resultData($result); - } - $this->emitJson(['tenants' => $payload, 'count' => count($results)]); - return self::SUCCESS; - } - - $rows = []; - foreach ($results as $id => $result) { - $rows[] = [$id, (string) $result->appliedCount(), (string) $result->batch]; - } - $this->table() - ->headers(['Tenant', 'Applied', 'Batch']) - ->rows($rows) - ->render(); - $this->alertSuccess('All tenants migrated', [ - 'Tenants: ' . count($results), - ]); - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Commands/Migrate/TenantMigrateStatusCommand.php b/src/Commands/Migrate/TenantMigrateStatusCommand.php deleted file mode 100644 index 1b86dce..0000000 --- a/src/Commands/Migrate/TenantMigrateStatusCommand.php +++ /dev/null @@ -1,73 +0,0 @@ -name = 'tenant:status'; - $this->description = 'Show migration status across one or all tenants'; - $this->registerTenantOptions(); - } - - protected function handle(): int - { - $runner = $this->tenantRunner(); - $tenant = $this->selectedTenant(); - - if ($tenant !== null) { - $status = $runner->statusForTenant($tenant); - - if ($this->wantsJson()) { - $this->emitJson(['tenant' => $tenant, 'status' => $status]); - return self::SUCCESS; - } - - $this->section("Status for tenant: {$tenant}"); - $rows = []; - foreach ($status as $name => $row) { - $rows[] = [ - $name, - (string) ($row['status'] ?? '?'), - $row['batch'] !== null ? (string) $row['batch'] : '—', - ]; - } - $this->table() - ->headers(['Migration', 'Status', 'Batch']) - ->rows($rows) - ->render(); - return self::SUCCESS; - } - - $all = $runner->statusForAllTenants(); - - if ($this->wantsJson()) { - $this->emitJson(['tenants' => $all]); - return self::SUCCESS; - } - - // Render a flat aggregate: tenant + migration name + status + batch - foreach ($all as $id => $status) { - $this->section("Tenant: {$id}"); - $rows = []; - foreach ($status as $name => $row) { - $rows[] = [ - $name, - (string) ($row['status'] ?? '?'), - $row['batch'] !== null ? (string) $row['batch'] : '—', - ]; - } - $this->table() - ->headers(['Migration', 'Status', 'Batch']) - ->rows($rows) - ->render(); - } - return self::SUCCESS; - } -} \ No newline at end of file diff --git a/src/Kernel/Boot/BootPipeline.php b/src/Kernel/Boot/BootPipeline.php index 7aadeb1..a4df4f6 100644 --- a/src/Kernel/Boot/BootPipeline.php +++ b/src/Kernel/Boot/BootPipeline.php @@ -31,8 +31,24 @@ */ final class BootPipeline { - /** @var list Ordered boot stages — order is fixed and meaningful. */ - private array $stages; + /** + * Stages that COMPILE — they read module.json / config and write manifests. + * Skippable when BootStamp says the manifests are already current. + * + * @var list + */ + private array $compileStages; + + /** + * Stages that VALIDATE live objects (port bindings, security layers). They + * touch no disk, produce no manifest and cost nothing, so they run on EVERY + * build — a cached boot must still refuse a missing port. + * + * @var list + */ + private array $validateStages; + + private ManifestReader $reader; /** * @param list $moduleClasses @@ -43,6 +59,12 @@ final class BootPipeline * @param list $disabledRoutes * Project route-disable policy (Kernel::withRoutePolicy). "METHOD /path" or a * module domain; applied to plugin routes before project routes are compiled. + * @param array $projectGroups + * Project route GROUPS + source-wide route defaults (Kernel::withRouteGroups). + * Expanded into flat routes by the route-manifest compiler. + * @param list $projectDomains + * Hosts this project serves (proj.json "domains"). A route grouped under a + * host that is not registered fails the boot — it could never be reached. */ public function __construct( private readonly array $moduleClasses, @@ -50,29 +72,42 @@ public function __construct( array $securityLayers = [], array $projectRoutes = [], array $disabledRoutes = [], + array $projectGroups = [], + array $projectDomains = [], + ?ManifestReader $reader = null, ) { // Single reader shared across every manifest-reading stage: each module.json // (the single source of truth) is read + JSON-decoded ONCE and cached, instead // of once per stage. The cache populates on the first stage to touch a module - // and every later stage hits it. - $reader = new ManifestReader(); + // and every later stage hits it. The caller may pass its own so it can reuse + // the same cache (and ask which files were read) afterwards. + $this->reader = $reader ??= new ManifestReader(); - $this->stages = [ + $this->compileStages = [ new ValidateConfigStage($moduleClasses, reader: $reader), // 1. env vars present + typed new DetectConflictsStage($moduleClasses, reader: $reader), // 2. no two modules share solves() new DetectCyclesStage($moduleClasses, reader: $reader), // 3. no circular requires[] chains - new CompileServiceManifestStage($moduleClasses, projectRoutes: $projectRoutes, reader: $reader), // 4. dep graph → service-manifest.php - new CompileRouteManifestStage($moduleClasses, projectRoutes: $projectRoutes, disabledRoutes: $disabledRoutes, reader: $reader), // 5. routes[] → route-manifest.php + new CompileServiceManifestStage($moduleClasses, projectRoutes: $projectRoutes, reader: $reader, projectGroups: $projectGroups), // 4. dep graph → service-manifest.php + new CompileRouteManifestStage($moduleClasses, projectRoutes: $projectRoutes, disabledRoutes: $disabledRoutes, reader: $reader, projectGroups: $projectGroups, projectDomains: $projectDomains), // 5. routes[] → route-manifest.php new CompileViewManifestStage($moduleClasses, reader: $reader), // 6. views[] → view-manifest.php (project-first cascade) new CompileLangManifestStage($moduleClasses, reader: $reader), // 7. lang[] → lang-manifest.php (project-first cascade) new CompileJobManifestStage($moduleClasses, reader: $reader), // 8. jobs[] → job-manifest.php new CompileCommandManifestStage($moduleClasses, reader: $reader), // 9. commands[] → command-manifest.php new CompileConfigManifestStage($moduleClasses), // 10. config/*.php → config-manifest.php (project over plugin) + ]; + + $this->validateStages = [ new RegisterPortsStage($core), // 11. Port → Adapter bindings validated new BindSecurityStage($securityLayers), // 12. SecurityGateway layers validated ]; } + /** The reader the compile stages used — ask it which files they read. */ + public function reader(): ManifestReader + { + return $this->reader; + } + /** * Run all stages in order. Fail fast on any error. * @@ -80,7 +115,25 @@ public function __construct( */ public function run(): void { - foreach ($this->stages as $stage) { + $this->runStages([...$this->compileStages, ...$this->validateStages]); + } + + /** + * Run ONLY the stages that validate live objects. + * + * Used when BootStamp reports the compiled manifests are already current: the + * compilation is skipped, but a missing port binding or an unusable security + * layer must still fail the boot. + */ + public function runValidationOnly(): void + { + $this->runStages($this->validateStages); + } + + /** @param list $stages */ + private function runStages(array $stages): void + { + foreach ($stages as $stage) { try { $stage->run(); } catch (BootException $e) { diff --git a/src/Kernel/Boot/BootStamp.php b/src/Kernel/Boot/BootStamp.php new file mode 100644 index 0000000..e7b5d35 --- /dev/null +++ b/src/Kernel/Boot/BootStamp.php @@ -0,0 +1,176 @@ + $inputs + */ + public static function hash(array $inputs): string + { + return hash('sha256', serialize($inputs)); + } + + /** + * The compiled manifests are current for these inputs. + * + * @return array{essentials: list}|null the cached derivations, or + * null when a real compile is required + */ + public static function read(string $configHash): ?array + { + if (!is_file(Paths::cache(self::SENTINEL))) { + return null; + } + + $stamp = ManifestReader::readCompiled(self::FILE); + + if (($stamp['config'] ?? null) !== $configHash) { + return null; + } + + foreach ($stamp['files'] ?? [] as $path => $signature) { + if (self::signature((string) $path) !== $signature) { + return null; + } + } + + // A config file ADDED since the last compile appears in no entry above, + // so compare how many each watched directory holds. + foreach ($stamp['dirs'] ?? [] as $dir => $count) { + if (self::countPhp((string) $dir) !== $count) { + return null; + } + } + + return ['essentials' => $stamp['essentials'] ?? []]; + } + + /** + * Record a successful compile. + * + * @param list $sourceFiles module.json paths the compile read + * @param list $essentials resolved essential-module classes + */ + public static function write(string $configHash, array $sourceFiles, array $essentials): void + { + $files = []; + $dirs = []; + + foreach ($sourceFiles as $file) { + $files[$file] = self::signature($file); + + // Each module.json sits beside the module's own config/ directory, + // which CompileConfigManifestStage globs. + self::watchConfigDir(dirname($file) . '/config', $files, $dirs); + } + + self::watchConfigDir(Paths::config(), $files, $dirs); + + ManifestWriter::write(self::FILE, [ + 'config' => $configHash, + 'files' => $files, + 'dirs' => $dirs, + 'essentials' => array_values($essentials), + ]); + } + + /** + * @param array $files + * @param array $dirs + */ + private static function watchConfigDir(string $dir, array &$files, array &$dirs): void + { + if (!is_dir($dir)) { + return; + } + + $found = glob(rtrim($dir, '/') . '/*.php') ?: []; + + $dirs[$dir] = count($found); + + foreach ($found as $file) { + $files[$file] = self::signature($file); + } + } + + /** mtime:size, or '' when the file is gone — which invalidates. */ + private static function signature(string $path): string + { + $stat = @stat($path); + + return $stat === false ? '' : $stat['mtime'] . ':' . $stat['size']; + } + + private static function countPhp(string $dir): int + { + return is_dir($dir) ? count(glob(rtrim($dir, '/') . '/*.php') ?: []) : -1; + } +} diff --git a/src/Kernel/Boot/ManifestReader.php b/src/Kernel/Boot/ManifestReader.php index d587366..95a0caa 100644 --- a/src/Kernel/Boot/ManifestReader.php +++ b/src/Kernel/Boot/ManifestReader.php @@ -15,6 +15,16 @@ final class ManifestReader /** @var array> */ private array $cache = []; + /** + * Absolute module.json paths this reader has read, in first-seen order. + * + * BootStamp records them (with mtime+size) so a later build can tell whether + * anything the compile depended on has actually changed. + * + * @var array + */ + private array $files = []; + /** * @param class-string $moduleClass * @return array @@ -37,6 +47,8 @@ public function read(string $moduleClass): array throw new BootException("module.json not found for [{$moduleClass}] — expected at {$path}"); } + $this->files[$path] = true; + $raw = file_get_contents($path); $decoded = $raw !== false ? json_decode($raw, true) : null; if (!is_array($decoded)) { @@ -46,6 +58,16 @@ public function read(string $moduleClass): array return $this->cache[$moduleClass] = $decoded; } + /** + * Every module.json this reader has read, absolute paths. + * + * @return list + */ + public function files(): array + { + return array_keys($this->files); + } + /** * Read a COMPILED manifest written by {@see ManifestWriter} (route-manifest.php, * config-manifest.php, …). The counterpart to ManifestWriter::write(). diff --git a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php index a795c21..0d08264 100644 --- a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php +++ b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php @@ -3,14 +3,37 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Boot\Stages; use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{BootException, ManifestReader, ManifestWriter}; -use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteParameter; - -/** Reads routes[] from every module.json -> route-manifest.php (OPcache-cached). */ +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\{RouteIndex, RouteParameter}; + +/** + * Reads routes[] from every module.json -> route-manifest.php (OPcache-cached). + * + * THREE ARTEFACTS, ONE COMPILATION + * -------------------------------- + * route-manifest.php the canonical flat map, `"METHOD /path" => entry`. Its + * shape is PUBLIC (RouteCatalog, tooling and tests read + * it), so it only ever gains keys, never changes shape. + * route-index.php the matcher's ready-to-use index: static table, per-method + * first-segment buckets, and a precompiled anchored regex + + * parameter list per dynamic route. Everything RouteMatcher + * used to derive on every worker's first request. + * route-names.php name => {path, method}. Lets UrlGenerator build URLs + * without loading the whole route table — the difference + * between a worker that mints one email link and one that + * holds the entire routing surface in memory. + * + * Both derived files are OPTIONAL at runtime: every consumer falls back to + * deriving from route-manifest.php, so a stale deploy that predates them still + * boots and serves. + */ final class CompileRouteManifestStage implements BootStageContract { /** Synthetic scope for project-layer routes (no owning module). */ public const PROJECT_SCOPE = '__project__'; + /** Guard against a self-referencing groups[] structure. */ + private const MAX_GROUP_DEPTH = 16; + /** * @param list $moduleClasses * @param list $projectRoutes @@ -27,6 +50,16 @@ public function __construct( private readonly array $projectRoutes = [], private readonly array $disabledRoutes = [], private readonly ManifestReader $reader = new ManifestReader(), + private readonly array $projectGroups = [], + /** + * Hosts this project serves — proj.json "domains", via + * Kernel::withProjectDomains(). A route grouped under a host that is not + * in here could never be reached, so it fails the boot. Empty (a project + * that registers no domains) disables the check entirely. + * + * @var list + */ + private readonly array $projectDomains = [], ) {} public function run(): void @@ -61,37 +94,43 @@ public function run(): void // consistent with project routes. foreach ($this->moduleClasses as $moduleClass) { $manifest = $manifests[$moduleClass]; - foreach ($manifest['routes'] ?? [] as $route) { - if (!isset($route['method'], $route['path'], $route['handler'])) { - throw new BootException( - "Invalid route in [{$moduleClass}] - each route needs method, path and handler." - ); - } - if (!str_contains($route['handler'], '@')) { - throw new BootException( - "Route handler [{$route['handler']}] in [{$moduleClass}] must be in 'Controller@method' format." - ); - } - $this->validateParameterTypes($route['path'], "Route in [{$moduleClass}]"); - $key = strtoupper($route['method']) . ' ' . $route['path']; + // Module-wide route defaults. "routePrefix" is prepended to every + // path the module declares and "routeFilters" is merged in FRONT of + // each route's own filters[], so an admin plugin declares `auth` + // once instead of on all forty routes. Both are optional and absent + // by default, so existing module.json files compile identically. + foreach ($this->flatten($manifest, "[{$moduleClass}]") as $route) { + $path = $route['path']; + $key = RouteIndex::key($route['method'], $route['domain'], $path); + if (isset($routes[$key])) { throw new BootException( "Duplicate route [{$key}] declared by [{$moduleClass}] and [{$routes[$key]['module']}]." ); } + + $filters = $route['filters']; + $requires = $this->validateRequires( + $route['requires'], + $knownDomains, + "Route [{$key}] in [{$moduleClass}]", + ); + $routes[$key] = [ 'handler' => $route['handler'], 'module' => $moduleClass, 'solves' => $manifest['solves'], 'name' => $this->routeName($route, $key, $names, "[{$moduleClass}]"), - 'filters' => $this->normalizeFilters($route['filters'] ?? []), - 'requires' => $this->validateRequires( - $this->normalizeRequires($route['requires'] ?? []), - $knownDomains, - "Route [{$key}] in [{$moduleClass}]", - ), - ]; + 'filters' => $filters, + 'requires' => $requires, + 'faces' => $route['faces'], + 'domain' => $route['domain'], + ] + $this->precompile($route['handler'], $path, $manifest['solves'], $filters, "Route [{$key}] in [{$moduleClass}]"); + + // requires[] is validated above but precompile() ran before it was + // stored; recompute the graph key now that the final list is known. + $routes[$key]['graph_key'] = $manifest['solves'] . '|' . implode(',', $requires); } } @@ -115,24 +154,24 @@ public function run(): void // any module.json. They carry no module and resolve under the synthetic // PROJECT_SCOPE, whose dependency graph is empty — so route-level // requires[] is the ONLY way a project page pulls in a plugin. - foreach ($this->projectRoutes as $route) { - if (!isset($route['method'], $route['path'], $route['handler'])) { - throw new BootException( - 'Invalid project route - each route needs method, path and handler.' - ); - } - if (!str_contains($route['handler'], '@')) { - throw new BootException( - "Project route handler [{$route['handler']}] must be in 'Controller@method' format." - ); - } + // withRoutes() routes and any routes[] declared alongside the groups are + // BOTH the project's, so they concatenate. A `+` union here would have + // let one silently drop the other — array union keeps the LEFT key, so a + // routes[] passed to withRouteGroups() would have vanished without a word. + $projectSource = $this->projectGroups; + $projectSource['routes'] = [ + ...$this->projectRoutes, + ...(is_array($projectSource['routes'] ?? null) ? $projectSource['routes'] : []), + ]; + + foreach ($this->flatten($projectSource, 'the project') as $route) { // DETERMINISTIC PRIORITY: project routes are compiled AFTER every // plugin route and OVERRIDE a plugin route declaring the same // "METHOD path". This is the default project-over-plugin precedence — // never the reverse. Plugins cannot reclaim a route the project owns. - $this->validateParameterTypes($route['path'], 'Project route'); + $path = $route['path']; - $key = strtoupper($route['method']) . ' ' . $route['path']; + $key = RouteIndex::key($route['method'], $route['domain'], $path); // A project override INHERITS the overridden plugin route's name // unless it declares its own. Overriding changes where a name points, @@ -148,26 +187,419 @@ public function run(): void $declared = $inherited; } + $filters = $route['filters']; + $requires = $this->validateRequires($route['requires'], $knownDomains, "Project route [{$key}]"); + $routes[$key] = [ 'handler' => $route['handler'], 'module' => null, 'solves' => self::PROJECT_SCOPE, 'name' => $declared, 'overrides' => $routes[$key]['module'] ?? null, - 'filters' => $this->normalizeFilters($route['filters'] ?? []), + 'filters' => $filters, // Per-route module dependencies seeded into this request's graph // by LoadStage. Each must name a real module domain — fail at boot. - 'requires' => $this->validateRequires( + 'requires' => $requires, + 'faces' => $route['faces'], + 'domain' => $route['domain'], + ] + $this->precompile($route['handler'], $path, self::PROJECT_SCOPE, $filters, "Project route [{$key}]"); + + $routes[$key]['graph_key'] = self::PROJECT_SCOPE . '|' . implode(',', $requires); + } + + ManifestWriter::write('route-manifest.php', $routes); + ManifestWriter::write('route-index.php', RouteIndex::build($routes)); + ManifestWriter::write('route-names.php', RouteIndex::names($routes)); + } + + // ── Groups ─────────────────────────────────────────────────────────────── + + /** + * Flatten a route declaration source — a module.json, a proj.json, or the + * array passed to Kernel::withRoutes() — into a plain list of fully-resolved + * routes. + * + * A source may declare routes directly, and/or nest them in `groups[]`, which + * may nest further: + * + * "routePrefix": "/api", // source-wide defaults + * "routeFilters": ["auth"], + * "routeRequires":["view.rendering"], + * "routeDomain": "africavoting.local", + * "groups": [ + * { "prefix": "/admin", "filters": ["throttle:30,1"], "name": "admin.", + * "subdomain": "admin", "routes": [ … ], "groups": [ … ] } + * ] + * + * A group exists to say a thing ONCE that would otherwise be repeated on every + * route inside it. The whole expansion happens here, at boot — the manifest, + * the matcher and every request-time stage only ever see flat routes, so + * grouping costs exactly nothing at runtime. + * + * INHERITANCE + * prefix concatenated outward-in + * name concatenated outward-in, prefixed onto each route's own name + * filters merged, de-duplicated BY ALIAS — inner wins, so a group's + * "throttle:60,1" is replaced (not doubled) by a route's "throttle:5,1" + * requires union + * domain inner overrides outer (written literally — a host, + * "*.wildcard", or a bare subdomain; grouped VERBATIM) + * faces inner overrides outer when non-empty + * + * Nothing SUBTRACTS. A group cannot strip a filter an outer group added: + * removal is the project's prerogative and lives in routePolicy.disable, which + * is the single place authorised to veto. + * + * @param array $source + * @return list, requires: list, domain: string, faces: list}> + */ + private function flatten(array $source, string $owner): array + { + return $this->flattenInto($source, [ + 'prefix' => $this->normalizePrefix($source['routePrefix'] ?? '', $owner), + 'name' => $this->stringOrEmpty($source['routeName'] ?? '', $owner, 'routeName'), + 'filters' => $this->normalizeFilters($source['routeFilters'] ?? [], $owner), + 'requires' => $this->normalizeRequires($source['routeRequires'] ?? []), + 'domain' => $this->checkedDomain( + $this->normalizeDomain($source['routeDomain'] ?? $source['routeSubdomain'] ?? ''), + $owner, + ), + 'faces' => $this->normalizeFaces($source['routeFaces'] ?? []), + ], $owner, 0); + } + + /** + * @param array $source + * @param array{prefix: string, name: string, filters: list, requires: list, domain: string, faces: list} $inherited + * @return list> + */ + private function flattenInto(array $source, array $inherited, string $owner, int $depth): array + { + if ($depth > self::MAX_GROUP_DEPTH) { + throw new BootException( + "Route groups in {$owner} nest more than " . self::MAX_GROUP_DEPTH . ' levels deep. ' + . 'That is almost always a self-referencing structure rather than an intended hierarchy.' + ); + } + + $flat = []; + + foreach ($source['routes'] ?? [] as $route) { + if (!is_array($route) || !isset($route['method'], $route['path'], $route['handler'])) { + throw new BootException( + "Invalid route in {$owner} - each route needs method, path and handler." + ); + } + + $path = $this->normalizePath( + $inherited['prefix'] . (string) $route['path'], + "Route in {$owner}", + ); + + $name = $this->stringOrEmpty($route['name'] ?? '', $owner, 'name'); + + $flat[] = [ + 'method' => strtoupper(trim((string) $route['method'])), + 'path' => $path, + 'handler' => (string) $route['handler'], + // An unnamed route stays unnamed: a group's name prefix labels + // routes that opted into a name, it does not invent names. + 'name' => $name === '' ? null : $inherited['name'] . $name, + 'filters' => $this->mergeFilters( + $inherited['filters'], + $this->normalizeFilters($route['filters'] ?? [], "Route in {$owner}"), + ), + 'requires' => $this->mergeRequires( + $inherited['requires'], $this->normalizeRequires($route['requires'] ?? []), - $knownDomains, - "Project route [{$key}]", ), + 'domain' => isset($route['domain']) || isset($route['subdomain']) + ? $this->checkedDomain( + $this->normalizeDomain($route['domain'] ?? $route['subdomain']), + "Route in {$owner}", + ) + : $inherited['domain'], + 'faces' => $this->normalizeFaces($route['faces'] ?? []) ?: $inherited['faces'], ]; } - ManifestWriter::write('route-manifest.php', $routes); + foreach ($source['groups'] ?? [] as $group) { + if (!is_array($group)) { + throw new BootException("Invalid route group in {$owner} - a group must be an object."); + } + + $flat = [...$flat, ...$this->flattenInto($group, [ + 'prefix' => $inherited['prefix'] + . $this->normalizePrefix($group['prefix'] ?? '', "Route group in {$owner}"), + 'name' => $inherited['name'] + . $this->stringOrEmpty($group['name'] ?? '', $owner, 'group name'), + 'filters' => $this->mergeFilters( + $inherited['filters'], + $this->normalizeFilters($group['filters'] ?? [], "Route group in {$owner}"), + ), + 'requires' => $this->mergeRequires( + $inherited['requires'], + $this->normalizeRequires($group['requires'] ?? []), + ), + 'domain' => isset($group['domain']) || isset($group['subdomain']) + ? $this->checkedDomain( + $this->normalizeDomain($group['domain'] ?? $group['subdomain']), + "Route group in {$owner}", + ) + : $inherited['domain'], + 'faces' => $this->normalizeFaces($group['faces'] ?? []) ?: $inherited['faces'], + ], $owner, $depth + 1)]; + } + + return $flat; + } + + /** + * The DOMAIN a group of routes answers on, taken verbatim. + * + * "domain": "africavoting.local" a host + * "domain": "*.africavoting.local" a wildcard + * "subdomain": "organizer" a bare label + * + * This GROUPS — it does not verify. The compiler does not resolve the string, + * look it up in any registry, or check that this deployment serves it: a + * domain nothing requests simply never matches, exactly like a path nothing + * requests. Only case and surrounding whitespace are normalised, plus the two + * characters that would break the route key round-trip ({@see + * RouteIndex::parseKey}) — a space and an '@', neither of which occurs in a + * hostname. + */ + private function normalizeDomain(mixed $domain): string + { + if (!is_string($domain)) { + return ''; + } + + $domain = strtolower(trim($domain)); + + return str_contains($domain, ' ') || str_contains($domain, RouteIndex::DOMAIN_SEPARATOR) + ? '' + : $domain; + } + + /** Validate a normalised domain and return it, so it composes in an expression. */ + private function checkedDomain(string $domain, string $context): string + { + $this->validateDomain($domain, $context); + + return $domain; + } + + /** + * Check that a declared HOST is one this project actually serves. + * + * The registry is the project's own `proj.json` "domains" — the same list + * DomainResolver matches an incoming Host against to build a DomainContext. + * Grouping routes under a host the project never registered produces routes + * that can never be reached: the request would have been routed to a + * different project, or refused, long before the router saw it. That is the + * same silent-404 class as an unknown `{name:type}` or a dead disable spec, + * so it fails the boot with the list of hosts that WOULD have worked. + * + * TWO THINGS ARE DELIBERATELY NOT CHECKED: + * + * - A BARE SUBDOMAIN (`"subdomain": "api"`, anything with no dot). It is + * domain-agnostic ON PURPOSE — it answers on api.example.com AND + * api.example2.com AND any future host with that first label — so there + * is no single registered host to check it against. + * - Anything at all when proj.json declares no "domains". A project that + * does not register its hosts has no registry to validate against, and + * inventing one is exactly the indirection this design avoids. + * + * A WILDCARD (`*.africavoting.local`) passes when the parent is registered or + * when any registered host falls under it — which is what makes it the right + * tool for tenant hosts that are added to the database, not to proj.json. + */ + private function validateDomain(string $domain, string $context): void + { + // No dot ⇒ a bare subdomain label, which spans every domain by design. + if ($domain === '' || $this->projectDomains === [] || !str_contains($domain, '.')) { + return; + } + + $wildcard = str_starts_with($domain, '*.'); + $suffix = $wildcard ? substr($domain, 2) : ''; + + foreach ($this->projectDomains as $host) { + $host = strtolower(trim((string) $host)); + + if ($wildcard + ? ($host === $suffix || str_ends_with($host, '.' . $suffix)) + : $host === $domain) { + return; + } + } + + throw new BootException(sprintf( + '%s groups routes under domain [%s], which this project does not serve. ' + . 'Registered domains (proj.json "domains"): %s. Add it there, use a wildcard ' + . 'like [*.%s], or declare a bare "subdomain" if the routes should answer on ' + . 'every domain.', + $context, + $domain, + implode(', ', $this->projectDomains), + ltrim(strstr($domain, '.') ?: $domain, '.'), + )); + } + + private function stringOrEmpty(mixed $value, string $owner, string $what): string + { + if ($value === null || $value === false || $value === '') { + return ''; + } + + if (!is_string($value)) { + throw new BootException("{$owner} declares a non-string {$what}."); + } + + return $value; + } + + /** + * @param list $inherited + * @param list $own + * @return list + */ + private function mergeRequires(array $inherited, array $own): array + { + if ($inherited === []) { + return $own; + } + + return array_values(array_unique([...$inherited, ...$own])); + } + + // ── Precompilation ─────────────────────────────────────────────────────── + + /** + * Everything about a route that is constant, computed once at boot so no + * request has to derive it: the handler split, the parsed filter specs, the + * dependency-graph cache key, and (for a dynamic path) the anchored regex. + * + * @param list $filters + * @return array + */ + private function precompile(string $handler, string $path, string $solves, array $filters, string $context): array + { + if (substr_count($handler, '@') !== 1) { + throw new BootException( + "{$context} has handler [{$handler}] — it must be in 'Controller@method' format " + . '(exactly one "@").' + ); + } + + [$class, $action] = explode('@', $handler, 2); + + if ($class === '' || $action === '') { + throw new BootException( + "{$context} has handler [{$handler}] — both the controller class and the method are required." + ); + } + + $compiled = ['class' => $class, 'action' => $action]; + + $this->verifyHandler($class, $action, $handler, $context); + + $specs = []; + foreach ($filters as $spec) { + $specs[] = self::parseFilterSpec($spec); + } + $compiled['filter_specs'] = $specs; + $compiled['graph_key'] = $solves . '|'; + + if (str_contains($path, '{')) { + $this->validateParameterTypes($path, $context); + + try { + $route = RouteParameter::compile($path); + } catch (\InvalidArgumentException $e) { + // A pattern that PCRE refuses compiles to a route which makes + // preg_match() return false on EVERY request — a permanent silent + // 404 that reads as a missing controller. Same anti-typo policy as + // unknown types, unknown requires[] domains and dead disable specs. + throw new BootException("{$context}: " . $e->getMessage(), previous: $e); + } + + $compiled['regex'] = $route['regex']; + $compiled['params'] = $route['params']; + } + + return $compiled; } + /** + * OPT-IN: check that the handler class and method actually exist. + * + * Off by default because it forces the autoloader to load every controller in + * the application at boot — real cost on a cold FPM process, and pointless in + * production where the routes demonstrably worked when the build was cut. + * Turn it on in development and CI (`ROUTE_VERIFY_HANDLERS=1`) and a renamed + * action fails the build with the route that references it, instead of 500ing + * the first time someone visits that page. + */ + private function verifyHandler(string $class, string $action, string $handler, string $context): void + { + static $enabled = null; + + $enabled ??= \function_exists('env') + && filter_var(env('ROUTE_VERIFY_HANDLERS', false), FILTER_VALIDATE_BOOL); + + if ($enabled !== true) { + return; + } + + if (!class_exists($class)) { + throw new BootException("{$context} references controller [{$class}], which does not exist."); + } + + if (!method_exists($class, $action)) { + throw new BootException( + "{$context} references [{$handler}], but [{$class}] has no method [{$action}]." + ); + } + + if (!(new \ReflectionMethod($class, $action))->isPublic()) { + throw new BootException( + "{$context} references [{$handler}], but [{$action}] is not public — " + . 'the pipeline can only invoke public controller actions.' + ); + } + } + + /** + * "throttle:60,1" => ['alias' => 'throttle', 'args' => ['60', '1']] + * + * Shared with RouteFilterStage, which used to run this on every request for + * every filter on the matched route. + * + * @return array{alias: string, args: list} + */ + public static function parseFilterSpec(string $spec): array + { + $spec = trim($spec); + + if (!str_contains($spec, ':')) { + return ['alias' => $spec, 'args' => []]; + } + + [$alias, $rawArgs] = explode(':', $spec, 2); + + return [ + 'alias' => trim($alias), + 'args' => array_values(array_filter( + array_map('trim', explode(',', $rawArgs)), + static fn(string $a): bool => $a !== '', + )), + ]; + } + + // ── Validation ─────────────────────────────────────────────────────────── + /** * Ensure every declared dependency names a domain some module solves(), * failing fast at boot with a descriptive message instead of a request-time @@ -215,9 +647,13 @@ private function applyDisablePolicy(array $routes): array $matched = 0; if ($isRouteKey) { - // Normalize "get /register" → "GET /register". - [$method, $path] = preg_split('/\s+/', $spec, 2) ?: [$spec, '']; - $key = strtoupper($method) . ' ' . $path; + // Normalize "get /register" → "GET /register", and + // "get@organizer /x" → "GET@organizer /x" (the domain stays + // lower-case — only the HTTP method is upper-cased). + [$verb, $path] = preg_split('/\s+/', $spec, 2) ?: [$spec, '']; + $parsed = RouteIndex::parseKey($verb . ' ' . $path); + $key = RouteIndex::key($parsed['method'], strtolower($parsed['domain']), $parsed['path']); + if (isset($routes[$key])) { unset($routes[$key]); $matched = 1; @@ -245,13 +681,6 @@ private function applyDisablePolicy(array $routes): array return $routes; } - /** - * Normalize a route's declared filters to a clean list of string specs. - * Accepts a single string ("auth") or a list (["auth", "throttle:60"]). - * - * @param mixed $filters - * @return list - */ /** * Resolve and claim a route's optional `"name"`. * @@ -314,17 +743,140 @@ private function validateParameterTypes(string $path, string $context): void } } - private function normalizeFilters(mixed $filters): array + /** + * A route path must be absolute. `Request::path()` always starts with '/', so + * a path declared as "users" compiles to the key "GET users" and can never be + * matched — an invisible dead endpoint. Fail loudly instead of prepending the + * slash, which would silently PUBLISH an endpoint the author believed was + * already live. + */ + private function normalizePath(string $path, string $context): string + { + if ($path === '' || $path[0] !== '/') { + throw new BootException( + "{$context} declares path [{$path}] which does not start with '/'. " + . 'Request paths are always absolute, so this route could never match.' + ); + } + + return $path; + } + + /** A module-wide route prefix: '' or an absolute path with no trailing slash. */ + private function normalizePrefix(mixed $prefix, string $context): string + { + if ($prefix === null || $prefix === '' || $prefix === false) { + return ''; + } + + if (!is_string($prefix)) { + throw new BootException("{$context} declares a non-string routePrefix."); + } + + $prefix = rtrim(trim($prefix), '/'); + + if ($prefix === '') { + return ''; + } + + if ($prefix[0] !== '/') { + throw new BootException( + "{$context} declares routePrefix [{$prefix}] which does not start with '/'." + ); + } + + return $prefix; + } + + /** + * Normalize a route's declared filters to a clean list of string specs. + * Accepts a single string ("auth") or a list (["auth", "throttle:60"]). + * + * @return list + */ + private function normalizeFilters(mixed $filters, string $context = 'A route'): array { if (is_string($filters)) { $filters = [$filters]; } + if ($filters === null || $filters === []) { + return []; + } if (!is_array($filters)) { + throw new BootException( + "{$context} declares filters that are neither a string nor a list." + ); + } + + $normalized = []; + foreach ($filters as $filter) { + if (!is_string($filter) && !is_int($filter) && !is_float($filter)) { + // Previously this hit "(string) $array" and produced the literal + // filter alias "Array", which then failed at request time. + throw new BootException( + "{$context} declares a filter that is not a string — filters are " + . 'aliases like "auth" or "throttle:60,1".' + ); + } + $filter = trim((string) $filter); + if ($filter !== '') { + $normalized[] = $filter; + } + } + + return $normalized; + } + + /** + * Module defaults first, then the route's own, de-duplicated by ALIAS so a + * route can re-declare "throttle:5,1" to override the module's "throttle:60,1" + * rather than running the stage twice. + * + * @param list $defaults + * @param list $own + * @return list + */ + private function mergeFilters(array $defaults, array $own): array + { + if ($defaults === []) { + return $own; + } + + $ownAliases = []; + foreach ($own as $spec) { + $ownAliases[self::parseFilterSpec($spec)['alias']] = true; + } + + $merged = []; + foreach ($defaults as $spec) { + if (!isset($ownAliases[self::parseFilterSpec($spec)['alias']])) { + $merged[] = $spec; + } + } + + return [...$merged, ...$own]; + } + + /** + * Optional face restriction — the project's DomainType values ('admin', 'api', + * …). Empty means "every face", which is what every existing route gets, so + * this is inert until a route opts in. The kernel stays domain-agnostic: it + * compares against the plain `route_face` request attribute and never imports + * the project's DomainContext. + * + * @return list + */ + private function normalizeFaces(mixed $faces): array + { + if (is_string($faces)) { + $faces = [$faces]; + } + if (!is_array($faces)) { return []; } return array_values(array_filter( - array_map(static fn($f): string => trim((string) $f), $filters), + array_map(static fn($f): string => strtolower(trim((string) $f)), $faces), static fn(string $f): bool => $f !== '', )); } diff --git a/src/Kernel/Boot/Stages/CompileServiceManifestStage.php b/src/Kernel/Boot/Stages/CompileServiceManifestStage.php index bdd7fba..01ca3e8 100644 --- a/src/Kernel/Boot/Stages/CompileServiceManifestStage.php +++ b/src/Kernel/Boot/Stages/CompileServiceManifestStage.php @@ -25,6 +25,8 @@ public function __construct( private readonly array $moduleClasses, private readonly array $projectRoutes = [], private readonly ManifestReader $reader = new ManifestReader(), + /** Project route groups — routes may be declared ONLY inside these. */ + private readonly array $projectGroups = [], ) {} public function run(): void @@ -73,7 +75,7 @@ public function run(): void // routes (Kernel::withRoutes). It has no module and no requires, so its // dependency graph is empty: the controller autowires from the request // container without running any module register(). - if ($this->projectRoutes !== []) { + if ($this->projectRoutes !== [] || ($this->projectGroups['groups'] ?? []) !== []) { $services[CompileRouteManifestStage::PROJECT_SCOPE] = [ 'name' => CompileRouteManifestStage::PROJECT_SCOPE, 'module' => null, diff --git a/src/Kernel/Kernel.php b/src/Kernel/Kernel.php index f9bc4b7..2073f97 100644 --- a/src/Kernel/Kernel.php +++ b/src/Kernel/Kernel.php @@ -3,7 +3,7 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel; -use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{BootException, BootPipeline, ManifestReader}; +use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\{BootException, BootPipeline, BootStamp, ManifestReader}; use AlfacodeTeam\PhpServicePlatform\Kernel\Config\Repository as ConfigRepository; use AlfacodeTeam\PhpServicePlatform\Kernel\Container\CoreContainer; use AlfacodeTeam\PhpServicePlatform\Kernel\Contracts\ModuleContract; @@ -14,6 +14,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\HttpPipeline; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Worker\{WorkerLoop, WorkerPipeline}; use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\LoggerPort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\{RouteIndex, UrlGenerator}; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\{SecurityGateway, Contracts\SecurityLayerContract}; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; @@ -47,6 +48,10 @@ final class Kernel private array $projectRoutes = []; /** @var array disable-spec => spec (de-duplicated, insertion order) */ private array $disabledRoutes = []; + /** @var array project route groups + source-wide route defaults */ + private array $projectGroups = []; + /** @var list hosts this project serves (proj.json "domains") */ + private array $projectDomains = []; private ?ErrorPipeline $errorPipeline = null; private ?\Closure $errorPipelineFun = null; private ?string $basePath = null; @@ -190,7 +195,83 @@ public function withEssentialModules(array $modules): self public function withRoutes(array $routes): self { foreach ($routes as $route) { - $this->projectRoutes[strtoupper($route['method'] ?? '') . ' ' . ($route['path'] ?? '')] = $route; + // The de-duplication key includes the DOMAIN group: two domains may + // legitimately declare the same "METHOD /path" with different + // handlers, and keying on method+path alone would silently drop one. + $domain = strtolower(trim((string) ($route['domain'] ?? $route['subdomain'] ?? ''))); + + $this->projectRoutes[RouteIndex::key( + (string) ($route['method'] ?? ''), + $domain, + (string) ($route['path'] ?? ''), + )] = $route; + } + return $this; + } + + /** + * Declare the project's route GROUPS and source-wide route defaults. + * + * A group states ONCE what would otherwise be repeated on every route inside + * it — a path prefix, filters, requires, a name prefix, and the DOMAIN the + * routes answer on. Groups may nest. + * + * ->withRouteGroups([ + * 'groups' => [ + * ['domain' => 'organizer', 'prefix' => '/dashboard', + * 'filters' => ['auth'], 'name' => 'organizer.', + * 'routes' => [ ... ]], + * ], + * ]) + * + * Because `domain` is part of the compiled route KEY, two domains may each + * define `GET /` with a different handler — this is how one project serves + * several brands, or grants a different access level per host, without + * forking. The domain is written literally (`africavoting.local`, + * `*.africavoting.local`, or a bare `organizer` subdomain) and is grouped + * verbatim: nothing resolves or validates it. + * + * The whole structure is expanded at BOOT into ordinary flat routes, so + * grouping costs nothing at request time. Merged shallowly with previous + * calls; `groups` accumulate so a base builder can contribute groups a child + * project extends. + * + * @param array $source + */ + public function withRouteGroups(array $source): self + { + $groups = [...($this->projectGroups['groups'] ?? []), ...($source['groups'] ?? [])]; + + $this->projectGroups = [...$this->projectGroups, ...$source]; + + if ($groups !== []) { + $this->projectGroups['groups'] = $groups; + } + + return $this; + } + + /** + * Declare the hosts this project serves — normally proj.json "domains", the + * same list DomainResolver matches an incoming Host against. + * + * Used to CHECK route domain groups at boot: grouping routes under a host the + * project never registered produces routes nothing can reach, because such a + * request would have been routed to another project (or refused) long before + * the router saw it. Declaring nothing here disables the check. + * + * A bare `"subdomain"` group is never checked — it answers on that label + * across every domain, so there is no single host to check it against. + * + * @param list $domains + */ + public function withProjectDomains(array $domains): self + { + foreach ($domains as $domain) { + $domain = strtolower(trim((string) $domain)); + if ($domain !== '' && !in_array($domain, $this->projectDomains, true)) { + $this->projectDomains[] = $domain; + } } return $this; } @@ -259,23 +340,73 @@ public function build(): self // deferred to materialize(), driven by whichever entry point is actually // used. A process that only serves HTTP never pays to build the worker // surface, and vice versa. - (new BootPipeline( + $reader = new ManifestReader(); + $pipeline = new BootPipeline( $this->moduleClasses, $this->core, $this->securityLayers, array_values($this->projectRoutes), array_values($this->disabledRoutes), - ))->run(); + $this->projectGroups, + $this->projectDomains, + $reader, + ); + + // BOOT CACHE (opt-in). Under PHP-FPM every request re-runs this method, + // recompiling manifests that are byte-identical to the last request's. + // When BOOT_CACHE is on and nothing the compile read has changed, skip + // the compilation and keep only the validation stages, which touch no + // disk and must still catch a missing port or an unusable layer. + $cached = BootStamp::enabled() ? BootStamp::read($this->buildHash()) : null; + + if ($cached !== null) { + $pipeline->runValidationOnly(); + // Recomputing these means re-reading every module.json — the very + // cost the cache exists to avoid — so they are cached alongside it. + $this->essentialModules = $cached['essentials']; + $this->built = true; + + return $this; + } + + $pipeline->run(); // Resolve essential DOMAIN entries (proj.json "essentials") to their // provider classes now that the module list is final. Fails the boot on - // an unknown domain — never a silent no-op essential. - $this->essentialModules = $this->resolveEssentialModules(); + // an unknown domain — never a silent no-op essential. Reuses the + // pipeline's reader, whose module.json cache is already warm. + $this->essentialModules = $this->resolveEssentialModules($reader); + + if (BootStamp::enabled()) { + BootStamp::write($this->buildHash(), $reader->files(), $this->essentialModules); + } $this->built = true; return $this; } + /** + * Everything the BUILDER contributes to compilation, as one hash. + * + * proj.json reaches the kernel as PHP arrays (routes, groups, domains, + * essentials, disable policy), so hashing these covers a proj.json edit + * without stat'ing it — and covers an edit to bootstrap/app.php itself, + * which no file-mtime check would catch. + */ + private function buildHash(): string + { + return BootStamp::hash([ + 'modules' => $this->moduleClasses, + 'essentials' => $this->essentialModules, + 'routes' => $this->projectRoutes, + 'groups' => $this->projectGroups, + 'disabled' => $this->disabledRoutes, + 'domains' => $this->projectDomains, + 'base' => $this->basePath, + 'project' => $this->projectPath, + ]); + } + /** * Resolve the essential-module list to provider classes. Entries containing * a namespace separator are class-strings and pass through; anything else is @@ -285,14 +416,13 @@ public function build(): self * @return list> * @throws BootException when a domain matches no registered module */ - private function resolveEssentialModules(): array + private function resolveEssentialModules(ManifestReader $reader): array { if ($this->essentialModules === []) { return []; } $byDomain = []; - $reader = new ManifestReader(); foreach ($this->moduleClasses as $class) { $m = $reader->read($class); if (isset($m['solves']) && is_string($m['solves'])) { @@ -366,6 +496,13 @@ private function materialize(RuntimeMode $mode): void $this->core->instance(ConfigRepository::class, $this->config()); // Expose kernel services to modules via the core container. + // UrlGenerator is a lazy singleton: a module that never builds a URL never + // pays for the route-name index, and one that does gets it injected rather + // than reaching for the global helper. + $this->core->singleton( + UrlGenerator::class, + static fn(): UrlGenerator => UrlGenerator::fromManifest((string) (env('APP_URL') ?: '')), + ); $this->core->instance(EventBus::class, $this->eventBus); $this->core->instance(WorkerPipeline::class, $this->workerPipe); $this->core->instance(HttpPipeline::class, $this->http); diff --git a/src/Kernel/Pipelines/Http/FilterRegistry.php b/src/Kernel/Pipelines/Http/FilterRegistry.php index aac0c3c..ab49a73 100644 --- a/src/Kernel/Pipelines/Http/FilterRegistry.php +++ b/src/Kernel/Pipelines/Http/FilterRegistry.php @@ -20,15 +20,21 @@ * $http->filter('auth', RequireAuthStage::class); * $http->filter('throttle', ApiRateLimitStage::class); * - * The alias map is global and stateless (built once at module boot). Stages are - * resolved per request from the CoreContainer, exactly like hook stages, so they - * remain OpenSwoole-safe (no per-request state on the registry). + * The alias map is global and stateless (built once at module boot). A resolved + * stage is MEMOIZED and shared across requests — the same lifetime HttpPipeline + * already gives its hook stages, and safe for the same reason: an + * HttpStageContract carries no per-request state (everything it needs travels on + * the Request). Without this, a route declaring `["auth","throttle:60,1"]` built + * two fresh stage objects on every single hit. */ final class FilterRegistry { /** @var array> */ private array $aliases = []; + /** @var array resolved once, reused */ + private array $instances = []; + /** * @param class-string $stageClass */ @@ -40,6 +46,13 @@ public function register(string $alias, string $stageClass): void ); } $this->aliases[$alias] = $stageClass; + unset($this->instances[$alias]); + } + + /** @return list every registered alias — used for boot-time validation. */ + public function aliases(): array + { + return array_keys($this->aliases); } public function has(string $alias): bool @@ -50,9 +63,15 @@ public function has(string $alias): bool /** * Resolve an alias to a stage instance (from the core container when bound, * otherwise a plain no-arg construction — mirrors HttpPipeline::resolveHook). + * + * Memoized: a stage is constructed at most once per worker. */ public function resolve(string $alias, CoreContainer $core): HttpStageContract { + if (isset($this->instances[$alias])) { + return $this->instances[$alias]; + } + if (!isset($this->aliases[$alias])) { throw new \InvalidArgumentException( "Unknown route filter alias [{$alias}]. Register it in a Provider::boot() via \$http->filter()." @@ -61,6 +80,6 @@ public function resolve(string $alias, CoreContainer $core): HttpStageContract $class = $this->aliases[$alias]; - return $core->has($class) ? $core->make($class) : new $class(); + return $this->instances[$alias] = $core->has($class) ? $core->make($class) : new $class(); } } diff --git a/src/Kernel/Pipelines/Http/HttpPipeline.php b/src/Kernel/Pipelines/Http/HttpPipeline.php index d20a3e2..483c6ab 100644 --- a/src/Kernel/Pipelines/Http/HttpPipeline.php +++ b/src/Kernel/Pipelines/Http/HttpPipeline.php @@ -12,6 +12,7 @@ CorrelationIdStage, SecurityStage, ResolveStage, LoadStage, RouteFilterStage, ExecuteStage, ErrorStage }; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteIndex; use AlfacodeTeam\PhpServicePlatform\Kernel\Security\SecurityGateway; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; @@ -111,14 +112,23 @@ private function buildStages(): array $manifest = $this->loadManifest('service-manifest.php', ['services' => []]); $this->calculator ??= new DependencyGraphCalculator($manifest); $this->loader ??= new OnDemandLoader($this->core, $this->essentialModules); - $this->matcher ??= new RouteMatcher($this->loadManifest('route-manifest.php', [])); + $index = $this->routeIndex(); + $this->matcher ??= RouteMatcher::fromCompiled( + $index, + self::flag('ROUTE_HEAD_FALLBACK', true), + self::policy(), + ); + + if (self::flag('ROUTE_STRICT_FILTERS', true)) { + $this->assertFiltersRegistered(RouteIndex::entries($index)); + } return [ new ErrorStage($this->errorPipeline), // outermost wrapper new CorrelationIdStage(), new SecurityStage($this->gateway), ...$this->resolveHook('after.security'), - new ResolveStage($this->matcher), + new ResolveStage($this->matcher, self::flag('ROUTE_METHOD_NOT_ALLOWED', false)), new LoadStage($this->calculator, $this->loader, self::essentialDomains($manifest, $this->essentialModules)), ...$this->resolveHook('after.load'), new RouteFilterStage($this->filters, $this->core), @@ -167,6 +177,92 @@ private function loadManifest(string $file, array $default): array return is_array($data) ? $data : $default; } + /** + * Prefer `route-index.php` — the matcher-ready index the boot compiler built, + * which removes per-worker regex construction entirely. A deploy whose cache + * predates that file falls back to deriving the index from the flat manifest, + * so an un-recompiled application still serves. + * + * @return array + */ + private function routeIndex(): array + { + $index = $this->loadManifest('route-index.php', []); + + if (isset($index['static']) || isset($index['dynamic'])) { + return $index; + } + + return RouteIndex::build($this->loadManifest('route-manifest.php', [])); + } + + /** + * Every filter alias a route names must have been registered by some + * Provider::boot(). Checked once, here, because this runs AFTER module boot + * (the compiler cannot know the aliases yet) and BEFORE the first request — + * turning a per-request 500 on an unreachable page into a startup failure + * that names the route. + * + * Set ROUTE_STRICT_FILTERS=false to fall back to the previous behaviour (the + * unknown alias throws when that one route is requested). The escape hatch + * exists because this check runs for the WHOLE table: an application that has + * been quietly serving with one mis-declared filter on a page nobody visits + * should be able to deploy the upgrade first and fix the route second. + * + * @param array> $entries + */ + private function assertFiltersRegistered(array $entries): void + { + foreach ($entries as $key => $entry) { + $specs = $entry['filter_specs'] ?? null; + $specs = is_array($specs) + ? array_column($specs, 'alias') + : array_map( + static fn($f): string => explode(':', trim((string) $f), 2)[0], + is_array($entry['filters'] ?? null) ? $entry['filters'] : [], + ); + + foreach ($specs as $alias) { + if ($alias === '' || $this->filters->has($alias)) { + continue; + } + + throw new \InvalidArgumentException(sprintf( + 'Route [%s] declares filter [%s], which no Provider::boot() registered. ' + . 'Registered aliases: %s. Register it with $http->filter() in the plugin that ' + . 'provides it, or remove it from the route.', + $key, + $alias, + $this->filters->aliases() === [] ? '(none)' : implode(', ', $this->filters->aliases()), + )); + } + } + } + + /** Read a boolean env flag once, at pipeline build. */ + private static function flag(string $key, bool $default): bool + { + $value = \function_exists('env') ? env($key) : null; + + if ($value === null || $value === '') { + return $default; + } + + return filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? $default; + } + + /** ROUTE_TRAILING_SLASH: strict (default) | ignore | redirect. */ + private static function policy(): string + { + $value = \function_exists('env') ? strtolower(trim((string) (env('ROUTE_TRAILING_SLASH') ?? ''))) : ''; + + return match ($value) { + RouteMatcher::TRAILING_IGNORE => RouteMatcher::TRAILING_IGNORE, + RouteMatcher::TRAILING_REDIRECT => RouteMatcher::TRAILING_REDIRECT, + default => RouteMatcher::TRAILING_STRICT, + }; + } + /** * Map the essential module classes to their solves domains via the compiled * service manifest, so LoadStage can seed them (and thus their transitive diff --git a/src/Kernel/Pipelines/Http/RouteMatcher.php b/src/Kernel/Pipelines/Http/RouteMatcher.php index 3a481cb..2df342e 100644 --- a/src/Kernel/Pipelines/Http/RouteMatcher.php +++ b/src/Kernel/Pipelines/Http/RouteMatcher.php @@ -3,15 +3,16 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http; -use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteParameter; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\{RouteIndex, RouteParameter}; /** - * RouteMatcher — matches a method+path against the compiled route manifest. + * RouteMatcher — matches a method+path against the compiled route index. * * Exact matches are an O(1) hash lookup. Parameterized routes ({id}, {slug}) * fall back to a regex scan with NAMED capture, so captured values are returned - * and forwarded to the controller. Dynamic routes are bucketed by HTTP method, - * so a request only scans the regexes registered for its own method. + * and forwarded to the controller. Dynamic routes are bucketed by HTTP method + * AND by their first literal path segment, so a request only tests the handful + * of patterns that could possibly match its prefix. * * Placeholders may be TYPED — `{id:num}`, `{slug:slug}`, `{path:any}` — which * narrows the segment pattern so a non-matching value 404s at the routing layer @@ -20,77 +21,329 @@ * * Static routes are checked BEFORE dynamic ones, so a literal `/users/me` always * wins over `/users/{id}` regardless of declaration order. Among dynamic routes - * the first match wins, in manifest order. + * the first match wins, in manifest order (preserved across the bucket split by + * each candidate's ordinal). * * Built once and reused across requests — it holds no per-request state. + * + * ── PERCENT-DECODING IS PART OF MATCHING ───────────────────────────────────── + * `Request::path()` is the RAW path: `%2F` is three ordinary characters, so it + * slips through `[^/]+` untouched. Handing that to a controller means the "one + * path segment" guarantee evaporates the moment anything decodes it — and it + * must decode it, or `/users/José` arrives as `Jos%C3%A9`. + * + * So a captured value is decoded and then RE-VALIDATED against its declared + * type. `/files/..%2F..%2Fetc%2Fpasswd` no longer satisfies `{name}`, because + * the decoded `../../etc/passwd` does not. Decoding runs only when the value + * actually contains '%', so the common case costs one strpos. */ final class RouteMatcher { - /** @var array> "METHOD /path" => entry (static routes) */ + /** Trailing-slash policies. `strict` is the historical behaviour. */ + public const TRAILING_STRICT = 'strict'; + public const TRAILING_IGNORE = 'ignore'; + public const TRAILING_REDIRECT = 'redirect'; + + /** @var array>> domain => "METHOD /path" => entry */ private array $static = []; /** - * Dynamic (parameterized) routes bucketed by HTTP method, so a request only - * scans the regexes for ITS method instead of the whole dynamic table. + * Dynamic (parameterized) routes bucketed by domain, then HTTP method, then by + * first literal path segment, plus a `wild` list for routes whose first + * segment is itself a placeholder. * - * @var array, entry: array}>> + * @var array>>, wild?: list>}>> */ private array $dynamic = []; - /** @param array> $manifest */ - public function __construct(array $manifest) + /** @var list every method some route answers — used to compute Allow. */ + private array $methods = []; + + /** Whether ANY route is grouped under a domain — lets the common case skip the loops. */ + private bool $grouped = false; + + /** + * Legacy constructor: accepts the FLAT route manifest and derives the index + * in-process. Kept because it is public API (tests and any consumer that + * builds a matcher from a hand-made array). Prefer {@see fromCompiled()}, + * which reads an index the boot compiler already built. + * + * @param array> $manifest + */ + public function __construct( + array $manifest, + private readonly bool $headFallback = true, + private readonly string $trailingSlash = self::TRAILING_STRICT, + ) { + $this->apply(RouteIndex::build($manifest)); + } + + /** + * Build from `route-index.php` — the index the boot compiler precomputed. + * + * @param array $index + */ + public static function fromCompiled( + array $index, + bool $headFallback = true, + string $trailingSlash = self::TRAILING_STRICT, + ): self { + $matcher = new self([], $headFallback, $trailingSlash); + $matcher->apply($index); + + return $matcher; + } + + /** @param array $index */ + private function apply(array $index): void + { + $this->static = is_array($index['static'] ?? null) ? $index['static'] : []; + $this->dynamic = is_array($index['dynamic'] ?? null) ? $index['dynamic'] : []; + $this->methods = is_array($index['methods'] ?? null) ? array_values($index['methods']) : []; + $this->grouped = is_array($index['domains'] ?? null) && $index['domains'] !== []; + } + + /** + * Whether ANY route is grouped under a domain. + * + * Lets a caller skip expanding the request host into candidate keys — which + * costs more than the match itself — when no route could possibly use them. + */ + public function hasDomainGroups(): bool { - foreach ($manifest as $key => $entry) { - [$method, $path] = explode(' ', $key, 2); + return $this->grouped; + } - if (!str_contains($path, '{')) { - $this->static[$key] = $entry; - continue; + /** + * @param list $domains the domain-group keys this request may match, + * MOST SPECIFIC FIRST — normally {@see RouteIndex::hostCandidates()} + * applied to the request host. Empty means only the shared group, + * which is every route in an application that groups nothing. + * + * @return array{entry: array, params: array}|null + */ + public function match(string $method, string $path, array $domains = []): ?array + { + $found = $this->lookup($method, $path, $domains); + + if ($found === null && $this->trailingSlash === self::TRAILING_IGNORE) { + $found = $this->lookup($method, self::alternateSlash($path), $domains); + } + + // HEAD is defined as GET without a body. Without this, every page on the + // site 404s for link checkers, uptime monitors and caches that probe with + // HEAD. ExecuteStage drops the body again on the way out. + if ($found === null && $this->headFallback && $method === 'HEAD') { + $found = $this->lookup('GET', $path, $domains); + + if ($found === null && $this->trailingSlash === self::TRAILING_IGNORE) { + $found = $this->lookup('GET', self::alternateSlash($path), $domains); } + } + + return $found; + } - $params = []; - $regex = preg_replace_callback( - RouteParameter::PLACEHOLDER, - static function (array $m) use (&$params): string { - $name = preg_replace('/[^a-zA-Z0-9_]/', '', $m[1]); - $type = $m[2] ?? ''; - $params[] = $name; - - // An unknown type already failed the boot in - // CompileRouteManifestStage, so by here it is always valid. - return '(?P<' . $name . '>' . RouteParameter::pattern($type) . ')'; - }, - $path, - ); - - $this->dynamic[$method][] = [ - 'regex' => '#^' . $regex . '$#', - 'params' => $params, - 'entry' => $entry, - ]; + /** + * The methods that DO answer this path — for a `405 Method Not Allowed` and + * its mandatory `Allow` header. Only ever called after a miss, so the extra + * cross-method scan never touches the hot path. + * + * @return list + */ + public function allowedMethods(string $path, array $domains = []): array + { + $allowed = []; + + foreach ($this->methods as $method) { + if ($this->lookup($method, $path, $domains) !== null) { + $allowed[] = $method; + } } + + if ($allowed !== [] && $this->headFallback && in_array('GET', $allowed, true) + && !in_array('HEAD', $allowed, true)) { + $allowed[] = 'HEAD'; + } + + return $allowed; } /** + * Under the `redirect` trailing-slash policy: the canonical path this one + * should be sent to, or null when the alternate form does not match either. + */ + public function canonicalPath(string $method, string $path, array $domains = []): ?string + { + if ($this->trailingSlash !== self::TRAILING_REDIRECT) { + return null; + } + + $alternate = self::alternateSlash($path); + + if ($alternate === $path || $this->lookup($method, $alternate, $domains) === null) { + return null; + } + + return $alternate; + } + + // ── internals ──────────────────────────────────────────────────────────── + + /** + * One exact lookup. + * + * Order is SPECIFICITY within each table, and static still beats dynamic: + * + * 1. each domain group's static routes, most specific first + * 2. the shared group's static routes + * 3. each domain group's dynamic routes, most specific first + * 4. the shared group's dynamic routes + * + * Doing all the static work before any dynamic work preserves the invariant + * that a literal `/users/me` always beats `/users/{id}` — if a domain group + * were searched end-to-end first, its `/users/{id}` would swallow the shared + * literal `/users/me`, which is the kind of surprise this router exists to + * avoid. Within that, a domain group wins over the shared group, so declaring + * `GET /` under `organizer` overrides the shared `GET /` on that host only. + * + * @param list $domains most specific first * @return array{entry: array, params: array}|null */ - public function match(string $method, string $path): ?array + private function lookup(string $method, string $path, array $domains = []): ?array { $key = $method . ' ' . $path; - if (isset($this->static[$key])) { - return ['entry' => $this->static[$key], 'params' => []]; + + // An application that groups nothing pays a single bool check for all of + // this and then behaves exactly as it did before domain groups existed. + if ($this->grouped) { + foreach ($domains as $domain) { + if (isset($this->static[$domain][$key])) { + return ['entry' => $this->static[$domain][$key], 'params' => []]; + } + } + } + + if (isset($this->static[''][$key])) { + return ['entry' => $this->static[''][$key], 'params' => []]; + } + + if ($this->grouped) { + foreach ($domains as $domain) { + $match = $this->scan($this->dynamic[$domain][$method] ?? null, $path); + if ($match !== null) { + return $match; + } + } + } + + return $this->scan($this->dynamic[''][$method] ?? null, $path); + } + + /** + * Scan one domain+method's dynamic candidates in declaration order. + * + * @param array{buckets?: array>>, wild?: list>}|null $bucketed + * @return array{entry: array, params: array}|null + */ + private function scan(?array $bucketed, string $path): ?array + { + if ($bucketed === null) { + return null; } - foreach ($this->dynamic[$method] ?? [] as $route) { - if (preg_match($route['regex'], $path, $matches) === 1) { - $params = []; - foreach ($route['params'] as $name) { - $params[$name] = $matches[$name] ?? ''; + $candidates = $bucketed['buckets'][RouteIndex::requestSegment($path)] ?? []; + $wild = $bucketed['wild'] ?? []; + + if ($wild === []) { + foreach ($candidates as $route) { + $match = $this->test($route, $path); + if ($match !== null) { + return $match; } - return ['entry' => $route['entry'], 'params' => $params]; + } + + return null; + } + + // Merge the two ordered lists on the fly (no allocation) so a wildcard + // route declared before a bucketed one still wins, exactly as it would + // have in a single flat scan. + $i = 0; + $j = 0; + $n = count($candidates); + $m = count($wild); + + while ($i < $n || $j < $m) { + if ($j >= $m || ($i < $n && $candidates[$i]['ord'] <= $wild[$j]['ord'])) { + $route = $candidates[$i++]; + } else { + $route = $wild[$j++]; + } + + $match = $this->test($route, $path); + if ($match !== null) { + return $match; } } return null; } + + /** + * Test one candidate, decoding and re-validating every captured value. + * + * A capture that decodes into something its type forbids is treated as NO + * MATCH rather than as an error, so a later route still gets its chance — + * identical to how a value that never matched the pattern behaves. + * + * @param array $route + * @return array{entry: array, params: array}|null + */ + private function test(array $route, string $path): ?array + { + if (preg_match($route['regex'], $path, $matches) !== 1) { + return null; + } + + $params = []; + + foreach ($route['params'] as $param) { + $name = is_array($param) ? $param['name'] : $param; + $type = is_array($param) ? ($param['type'] ?? '') : ''; + $value = $matches[$name] ?? ''; + + if ($value !== '' && str_contains($value, '%')) { + $decoded = rawurldecode($value); + + if ($decoded !== $value) { + // A NUL byte truncates strings in every C-backed API it later + // reaches (filesystem, some DB drivers) — never let one through. + if (str_contains($decoded, "\0")) { + return null; + } + + if (preg_match('#^' . RouteParameter::pattern($type) . '$#D', $decoded) !== 1) { + return null; + } + + $value = $decoded; + } + } + + $params[$name] = $value; + } + + return ['entry' => $route['entry'], 'params' => $params]; + } + + /** '/users/' <-> '/users'. The root path has no alternate form. */ + private static function alternateSlash(string $path): string + { + if ($path === '/' || $path === '') { + return $path; + } + + return str_ends_with($path, '/') ? rtrim($path, '/') : $path . '/'; + } } diff --git a/src/Kernel/Pipelines/Http/Stages/ExecuteStage.php b/src/Kernel/Pipelines/Http/Stages/ExecuteStage.php index b79dcad..b931e40 100644 --- a/src/Kernel/Pipelines/Http/Stages/ExecuteStage.php +++ b/src/Kernel/Pipelines/Http/Stages/ExecuteStage.php @@ -15,7 +15,15 @@ public function handle(Request $request, callable $next): Response $container = $request->container(); $scope = $entry['solves'] ?? ''; - [$controllerClass, $method] = explode('@', $entry['handler']); + // The handler split is constant per route, so the boot compiler bakes it + // into the entry. explode() is the fallback for a manifest compiled by an + // older kernel. + if (isset($entry['class'], $entry['action'])) { + $controllerClass = $entry['class']; + $method = $entry['action']; + } else { + [$controllerClass, $method] = explode('@', $entry['handler'], 2); + } $controller = $container->makeInScope($controllerClass, $scope); @@ -32,6 +40,25 @@ public function handle(Request $request, callable $next): Response $response = $controller->$method($request, ...$params); } - return $response->withHeader('X-Correlation-ID', $request->attribute('correlation_id', '')); + $response = $response->withHeader('X-Correlation-ID', $request->attribute('correlation_id', '')); + + // A HEAD request is served by the GET route (see RouteMatcher) and must + // return the GET headers with NO body. Rebuilding an empty response also + // discards any stream callback or file path, so a HEAD probe on a large + // download never reads the file. + return $request->method() === 'HEAD' ? self::withoutBody($response) : $response; + } + + /** + * Same status and headers, empty body. Content-Length is dropped rather than + * faked: RFC 9110 permits omitting it on a HEAD response, and computing it + * would mean generating the very body we are trying not to produce. + */ + private static function withoutBody(Response $response): Response + { + $headers = $response->headers(); + unset($headers['content-length'], $headers['Content-Length']); + + return Response::empty($response->status())->withHeaders($headers); } } diff --git a/src/Kernel/Pipelines/Http/Stages/LoadStage.php b/src/Kernel/Pipelines/Http/Stages/LoadStage.php index c0e5844..9737cc4 100644 --- a/src/Kernel/Pipelines/Http/Stages/LoadStage.php +++ b/src/Kernel/Pipelines/Http/Stages/LoadStage.php @@ -42,6 +42,13 @@ public function handle(Request $request, callable $next): Response $entry = $request->attribute('route_entry'); $extra = is_array($entry) && is_array($entry['requires'] ?? null) ? $entry['requires'] : []; + // The cache key is constant per route, so the boot compiler bakes it into + // the entry as `graph_key`. The implode() below is the fallback for a + // manifest compiled by an older kernel. + $key = is_array($entry) && is_string($entry['graph_key'] ?? null) + ? $entry['graph_key'] + : $service . '|' . implode(',', $extra); + // Essential modules are registered on every request anyway (see // OnDemandLoader) — resolving their domains THROUGH the graph as well // brings their transitive requires[] with them, so an essential like @@ -49,7 +56,6 @@ public function handle(Request $request, callable $next): Response // unbound contract on routes that never pulled it in. The calculator // visits each domain once, so nothing registers twice. Graphs are // memoized per worker (see $graphs). - $key = $service . '|' . implode(',', $extra); $graph = $this->graphs[$key] ??= $this->calculator->resolve($service, [...$extra, ...$this->essentialDomains]); $container = $this->loader->load($graph, $request); diff --git a/src/Kernel/Pipelines/Http/Stages/ResolveStage.php b/src/Kernel/Pipelines/Http/Stages/ResolveStage.php index c48de4f..006d1a7 100644 --- a/src/Kernel/Pipelines/Http/Stages/ResolveStage.php +++ b/src/Kernel/Pipelines/Http/Stages/ResolveStage.php @@ -6,18 +6,47 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Http\{Request, Response}; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\Contracts\HttpStageContract; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\RouteMatcher; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\RouteIndex; +/** + * ResolveStage — turns method+path into the route entry the rest of the pipeline + * runs on, or ends the request with a 404 before any module is loaded. + * + * Three optional behaviours, all OFF or inert by default so an existing app + * resolves exactly as it did: + * + * - `405 Method Not Allowed` (+ Allow header) instead of 404 when the path + * exists under a different method. Opt-in, because a 405 confirms that a + * path exists and so hands a scanner free reconnaissance; a 404 does not. + * - a trailing-slash redirect, under the `redirect` policy. + * - a face restriction: a route may declare `faces: ["admin"]` and is then + * invisible on any other face. The kernel stays domain-agnostic — it reads + * the plain `route_face` request attribute and never imports the project's + * DomainContext. A mismatch 404s rather than 403s: a route the caller may + * not reach on this host should not advertise that it exists elsewhere. + */ final class ResolveStage implements HttpStageContract { public function __construct( - private readonly RouteMatcher $matcher + private readonly RouteMatcher $matcher, + private readonly bool $methodNotAllowed = false, ) {} public function handle(Request $request, callable $next): Response { - $match = $this->matcher->match($request->method(), $request->path()); + $method = $request->method(); + $path = $request->path(); + // Expanding the host into candidate keys costs more than the match + // itself, so an application that groups nothing never pays for it. + $domains = $this->matcher->hasDomainGroups() ? self::domains($request) : []; + + $match = $this->matcher->match($method, $path, $domains); if ($match === null) { + return $this->miss($method, $path, $domains); + } + + if (!$this->faceAllows($request, $match['entry'])) { return Response::notFound(); } @@ -28,4 +57,76 @@ public function handle(Request $request, callable $next): Response return $next($request); } + + /** + * The domain-group keys this request may match, most specific first. + * + * The host comes from the `route_host` attribute when an entry point set one + * — that is DomainContext->host, which DomainResolver already matched against + * projects.json. Otherwise it falls back to `Request::host()`, the raw Host + * header. Prefer the attribute: the header is client-controlled and no + * trusted-host allowlist filters it here, so on the fallback path a caller + * can choose which domain group serves it. + * + * @return list + */ + private static function domains(Request $request): array + { + $host = $request->attribute('route_host'); + + return RouteIndex::hostCandidates( + is_string($host) && $host !== '' ? $host : $request->host(), + ); + } + + /** + * No route matched: redirect to the canonical form, 405, or 404. + * + * @param list $domains + */ + private function miss(string $method, string $path, array $domains): Response + { + $canonical = $this->matcher->canonicalPath($method, $path, $domains); + if ($canonical !== null) { + return Response::permanentRedirect($canonical); + } + + if ($this->methodNotAllowed) { + $allowed = $this->matcher->allowedMethods($path, $domains); + + if ($allowed !== []) { + return Response::json([ + 'error' => [ + 'code' => 'method_not_allowed', + 'message' => "The {$method} method is not supported for this route.", + ], + ], 405)->withHeader('Allow', implode(', ', $allowed)); + } + } + + return Response::notFound(); + } + + /** + * @param array $entry + */ + private function faceAllows(Request $request, array $entry): bool + { + $faces = $entry['faces'] ?? []; + + if (!is_array($faces) || $faces === []) { + return true; // unrestricted — every route, unless it opted in + } + + $face = $request->attribute('route_face'); + + if (!is_string($face) || $face === '') { + // Nothing declared the current face (CLI-driven tests, an entry point + // that does not resolve a domain). Restricting on unknown information + // would silently 404 the route everywhere. + return true; + } + + return in_array(strtolower($face), $faces, true); + } } diff --git a/src/Kernel/Pipelines/Http/Stages/RouteFilterStage.php b/src/Kernel/Pipelines/Http/Stages/RouteFilterStage.php index 2639be4..2998132 100644 --- a/src/Kernel/Pipelines/Http/Stages/RouteFilterStage.php +++ b/src/Kernel/Pipelines/Http/Stages/RouteFilterStage.php @@ -3,6 +3,7 @@ namespace AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\Stages; +use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\Stages\CompileRouteManifestStage; use AlfacodeTeam\PhpServicePlatform\Kernel\Container\CoreContainer; use AlfacodeTeam\PhpServicePlatform\Kernel\Http\{Request, Response}; use AlfacodeTeam\PhpServicePlatform\Kernel\Pipelines\Http\Contracts\HttpStageContract; @@ -28,6 +29,10 @@ * (keyed by alias) so a stage can read its own configuration per route: * * $args = $request->attribute('filter_args')['throttle'] ?? []; + * + * The spec parse is CONSTANT per route, so the boot compiler stores the result as + * `filter_specs` and this stage reads it; the inline parse below is only the + * fallback for a manifest compiled by an older kernel. */ final class RouteFilterStage implements HttpStageContract { @@ -38,10 +43,10 @@ public function __construct( public function handle(Request $request, callable $next): Response { - $entry = $request->attribute('route_entry'); - $filters = is_array($entry) ? ($entry['filters'] ?? []) : []; + $entry = $request->attribute('route_entry'); + $specs = $this->specs($entry); - if (!is_array($filters) || $filters === []) { + if ($specs === []) { return $next($request); } @@ -50,12 +55,12 @@ public function handle(Request $request, callable $next): Response $aliases = []; $args = []; - foreach ($filters as $spec) { - [$alias, $params] = $this->parse((string) $spec); - $stages[] = $this->registry->resolve($alias, $this->core); + foreach ($specs as $spec) { + $alias = $spec['alias']; + $stages[] = $this->registry->resolve($alias, $this->core); $aliases[] = $alias; - if ($params !== []) { - $args[$alias] = $params; + if ($spec['args'] !== []) { + $args[$alias] = $spec['args']; } } @@ -78,24 +83,30 @@ public function handle(Request $request, callable $next): Response } /** - * "throttle:60,1" => ['throttle', ['60', '1']] - * "auth" => ['auth', []] - * - * @return array{0: string, 1: list} + * @param mixed $entry + * @return list}> */ - private function parse(string $spec): array + private function specs(mixed $entry): array { - $spec = trim($spec); - if (!str_contains($spec, ':')) { - return [$spec, []]; + if (!is_array($entry)) { + return []; + } + + $specs = $entry['filter_specs'] ?? null; + if (is_array($specs)) { + return $specs; } - [$alias, $rawArgs] = explode(':', $spec, 2); - $params = array_values(array_filter( - array_map('trim', explode(',', $rawArgs)), - static fn(string $a): bool => $a !== '', - )); + $filters = $entry['filters'] ?? []; + if (!is_array($filters) || $filters === []) { + return []; + } + + $parsed = []; + foreach ($filters as $spec) { + $parsed[] = CompileRouteManifestStage::parseFilterSpec((string) $spec); + } - return [trim($alias), $params]; + return $parsed; } } diff --git a/src/Kernel/Routing/RouteIndex.php b/src/Kernel/Routing/RouteIndex.php new file mode 100644 index 0000000..40b8a3e --- /dev/null +++ b/src/Kernel/Routing/RouteIndex.php @@ -0,0 +1,300 @@ + ["GET /health" => entry, …], + * 'dynamic' => ['GET' => [ + * 'buckets' => ['users' => [candidate, …]], // keyed by first LITERAL segment + * 'wild' => [candidate, …], // first segment is a placeholder + * ]], + * 'methods' => ['GET', 'POST', …], + * ] + * + * where a candidate is `['ord' => int, 'regex' => string, 'params' => […], 'entry' => […]]`. + * + * WHY BUCKETS + * ----------- + * Matching used to scan every dynamic pattern registered for the request's + * method. A path can only be matched by a dynamic route whose first segment is + * either the same literal or a placeholder, so bucketing by that segment reduces + * the scan to the few patterns that can possibly match. `ord` preserves manifest + * order across the bucket/wild split, so first-match-wins is unchanged. + * + * DOMAINS + * ------- + * A route may be grouped under a DOMAIN — written literally, as the host or the + * subdomain it answers on: + * + * { "domain": "africavoting.local", "routes": [ … ] } + * { "domain": "*.africavoting.local", "routes": [ … ] } + * { "subdomain": "organizer", "routes": [ … ] } + * + * The compiler GROUPS by that string verbatim — it does not resolve it, look it + * up anywhere, or check that it is a host this deployment serves. It is part of + * the route KEY, which is the whole point: `GET /` can exist once per domain with + * a different handler each time. '' is the shared group every ungrouped route + * lives in. + * + * Matching a request is the reverse: {@see hostCandidates()} expands the incoming + * host into the group keys that could hold it, most specific first, and the + * matcher tries each and then the shared group. + */ +final class RouteIndex +{ + /** Separates the HTTP method from the domain in a route key: `GET@organizer /path`. */ + public const DOMAIN_SEPARATOR = '@'; + + /** + * Build a route key. Ungrouped routes keep the historical `"METHOD /path"` + * exactly, so every existing manifest entry and every consumer that splits on + * the first space is unaffected. + */ + public static function key(string $method, string $domain, string $path): string + { + return strtoupper($method) + . ($domain === '' ? '' : self::DOMAIN_SEPARATOR . $domain) + . ' ' . $path; + } + + /** + * Split a route key back into its parts. + * + * Note the shape of a grouped key: the domain rides on the METHOD segment, so + * `explode(' ', $key, 2)` still yields a clean, leading-slash path for any + * consumer that has not been taught about domain groups. Such a consumer sees + * the method as `GET@organizer`, which no HTTP method matches, so it SKIPS the + * route rather than emitting a corrupted path. Failing safe was the deciding + * factor in choosing this format. + * + * @return array{method: string, domain: string, path: string} + */ + public static function parseKey(string $key): array + { + [$verb, $path] = array_pad(explode(' ', $key, 2), 2, ''); + + $at = strpos($verb, self::DOMAIN_SEPARATOR); + + return $at === false + ? ['method' => $verb, 'domain' => '', 'path' => $path] + : ['method' => substr($verb, 0, $at), 'domain' => substr($verb, $at + 1), 'path' => $path]; + } + + /** + * The domain-group keys an incoming host could match, MOST SPECIFIC FIRST. + * + * organizer.africavoting.local + * → organizer.africavoting.local the exact host + * → *.africavoting.local a wildcard on each parent suffix + * → *.local + * → organizer the bare subdomain label + * + * So a group may be declared as a full host, a wildcard, or just a subdomain, + * and the most specific declaration wins. Nothing is validated: a key nothing + * expands to simply never matches, exactly like a path nothing requests. + * + * @return list + */ + public static function hostCandidates(string $host): array + { + // Same normalisation DomainResolver applies: lower-case, no port, no + // trailing dot, IPv6 brackets unwrapped. + $host = strtolower(trim($host)); + $host = trim(explode(':', ltrim($host, '['), 2)[0], "].\t\n\r "); + + if ($host === '') { + return []; + } + + $candidates = [$host]; + $labels = explode('.', $host); + $count = count($labels); + + for ($i = 1; $i < $count; $i++) { + $candidates[] = '*.' . implode('.', array_slice($labels, $i)); + } + + // A bare label only means "subdomain" when there is one to speak of: + // for "hkmvote.local", "hkmvote" is the site itself, not a subdomain. + if ($count > 2) { + $candidates[] = $labels[0]; + } + + return $candidates; + } + /** + * @param array> $routes flat manifest, "METHOD /path" => entry + * @return array{static: array>>, dynamic: array>>, wild?: list>}>>, methods: list, domains: list} + */ + public static function build(array $routes): array + { + $static = []; + $dynamic = []; + $methods = []; + $domains = []; + $ordinal = 0; + + foreach ($routes as $key => $entry) { + ['method' => $method, 'domain' => $domain, 'path' => $path] = self::parseKey((string) $key); + + $methods[$method] = true; + if ($domain !== '') { + $domains[$domain] = true; + } + + // The inner key drops the domain — it is already the outer dimension + // — so a lookup is $static[$domain]["GET /path"]. + $innerKey = $method . ' ' . $path; + + if (!str_contains($path, '{')) { + $static[$domain][$innerKey] = $entry; + continue; + } + + $regex = $entry['regex'] ?? null; + $params = $entry['params'] ?? null; + + if (!is_string($regex) || !is_array($params)) { + try { + $compiled = RouteParameter::compile($path); + } catch (\InvalidArgumentException) { + // A path PCRE cannot represent. The boot compiler rejects + // these outright; here — reading a manifest compiled by an + // older kernel — dropping just this route preserves the old + // behaviour (that one endpoint 404s) instead of taking the + // whole pipeline down. + continue; + } + $regex = $compiled['regex']; + $params = $compiled['params']; + } + + $candidate = [ + 'ord' => $ordinal++, + 'key' => $key, + 'regex' => $regex, + 'params' => $params, + 'entry' => $entry, + ]; + + $segment = self::firstLiteralSegment($path); + + if ($segment === null) { + $dynamic[$domain][$method]['wild'][] = $candidate; + } else { + $dynamic[$domain][$method]['buckets'][$segment][] = $candidate; + } + } + + return [ + 'static' => $static, + 'dynamic' => $dynamic, + 'methods' => array_keys($methods), + 'domains' => array_keys($domains), + ]; + } + + /** + * The first path segment when it is literal, or null when it contains a + * placeholder (and so cannot be used as a bucket key). + */ + public static function firstLiteralSegment(string $path): ?string + { + $rest = ltrim($path, '/'); + $slash = strpos($rest, '/'); + $first = $slash === false ? $rest : substr($rest, 0, $slash); + + return str_contains($first, '{') ? null : $first; + } + + /** The first segment of a REQUEST path — the bucket key to look up. */ + public static function requestSegment(string $path): string + { + $rest = ltrim($path, '/'); + $slash = strpos($rest, '/'); + + return $slash === false ? $rest : substr($rest, 0, $slash); + } + + /** + * Every route entry in an index, keyed by "METHOD /path" — for callers that + * need to walk all routes (boot-time validation) without also loading the + * flat manifest and holding a second copy of the table. + * + * @param array $index + * @return array> + */ + public static function entries(array $index): array + { + $entries = []; + + foreach (is_array($index['static'] ?? null) ? $index['static'] : [] as $domain => $table) { + foreach ($table as $innerKey => $entry) { + ['method' => $method, 'path' => $path] = self::parseKey((string) $innerKey); + $entries[self::key($method, (string) $domain, $path)] = $entry; + } + } + + foreach (is_array($index['dynamic'] ?? null) ? $index['dynamic'] : [] as $byMethod) { + foreach (is_array($byMethod) ? $byMethod : [] as $bucketed) { + $lists = [...array_values($bucketed['buckets'] ?? []), $bucketed['wild'] ?? []]; + + foreach ($lists as $list) { + foreach ($list as $candidate) { + $entries[$candidate['key'] ?? ''] = $candidate['entry'] ?? []; + } + } + } + } + + unset($entries['']); + + return $entries; + } + + /** + * name => {path, method, domain} — everything UrlGenerator needs, nothing else. + * + * Names stay a FLAT, application-wide namespace even with domain groups: two + * domains that both want a route called `home` must name them `vote.home` and + * `africa.home` (a group's `name` prefix makes that one declaration). The + * alternative — per-domain names — would force UrlGenerator to know which + * domain it is generating for, and it deliberately holds no request state so + * that CLI commands and queue workers can build links at all. + * + * @param array> $routes + * @return array + */ + public static function names(array $routes): array + { + $names = []; + + foreach ($routes as $key => $entry) { + $name = $entry['name'] ?? null; + if (!is_string($name) || $name === '') { + continue; + } + ['method' => $method, 'domain' => $domain, 'path' => $path] = self::parseKey((string) $key); + $names[$name] = ['path' => $path, 'method' => $method, 'domain' => $domain]; + } + + return $names; + } +} \ No newline at end of file diff --git a/src/Kernel/Routing/RouteParameter.php b/src/Kernel/Routing/RouteParameter.php index ddea824..9b7785d 100644 --- a/src/Kernel/Routing/RouteParameter.php +++ b/src/Kernel/Routing/RouteParameter.php @@ -6,8 +6,8 @@ /** * Route parameter types — the `{name:type}` grammar. * - * Shared by CompileRouteManifestStage (which VALIDATES type names at boot) and - * RouteMatcher (which compiles them to regex at match time), so the two can + * Shared by CompileRouteManifestStage (which VALIDATES type names and PRECOMPILES + * the regex at boot) and RouteMatcher (which matches with it), so the two can * never disagree about what a type means. * * WHY TYPES EXIST @@ -38,6 +38,25 @@ * route must repeat the plugin's exact path, type suffix included. This is the * same literal-match rule that already governs overrides; typing does not * loosen it. + * + * ── ADDITIONS ──────────────────────────────────────────────────────────────── + * + * `path` — like `any` (crosses '/') but REFUSES a `..` sequence and control + * characters. `any` is kept byte-for-byte as it was, so nothing + * regresses; `path` is what a file-serving route should use. + * `enum(a|b)` — a closed set of literal values. Members are restricted to + * `[A-Za-z0-9_.-]` and are preg_quote'd, so the grammar can never + * inject regex metacharacters (no ReDoS surface from JSON). + * `{id?}` — an OPTIONAL parameter. The separator in front of it is folded + * into the optional group, so `/posts/{page?}` matches `/posts` + * as well as `/posts/2`. Omitted parameters are reported as ''. + * + * DECODING CONTRACT + * ----------------- + * {@see RouteMatcher} matches against the RAW (percent-encoded) request path and + * then decodes each captured value and RE-VALIDATES it against this table. A + * type therefore constrains what the CONTROLLER receives, not merely what the + * wire bytes looked like — `%2F` cannot smuggle a '/' past `{id}` any more. */ final class RouteParameter { @@ -54,23 +73,37 @@ final class RouteParameter 'uuid' => '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', 'segment' => '[^/]+', 'any' => '.*', + // Traversal-safe catch-all: crosses '/', but no '..' anywhere and no + // control characters. Prefer this over `any` for anything that reaches a + // filesystem or a StoragePort. + 'path' => '(?!(?s:.*)\.\.)[^\x00-\x1f\x7f]+', ]; /** The pattern used when no type is given — unchanged from before typing existed. */ public const DEFAULT_PATTERN = '[^/]+'; - /** Matches one `{name}` or `{name:type}` placeholder. */ - public const PLACEHOLDER = '/\{([^}:]+)(?::([^}]+))?\}/'; + /** + * Matches one placeholder: `{name}`, `{name:type}`, `{name?}`, `{name:type?}`. + * + * The NAME class stays permissive (anything but `}`, `:` and `?`) so paths + * that already rely on the matcher's name sanitisation — `{user-id}` — keep + * compiling exactly as they did. Tightening it here would silently turn a + * working route into a never-matching literal. + */ + public const PLACEHOLDER = '/\{([^}:?]+)(?::([^}?]+))?(\?)?\}/'; + + /** A well-formed `enum(a|b|c)` type. Members may not contain regex metacharacters. */ + private const ENUM = '/^enum\(([A-Za-z0-9_.-]+(?:\|[A-Za-z0-9_.-]+)*)\)$/'; /** @return list every valid type name, for error messages */ public static function names(): array { - return array_keys(self::TYPES); + return [...array_keys(self::TYPES), 'enum(a|b|…)']; } public static function isValidType(string $type): bool { - return isset(self::TYPES[$type]); + return isset(self::TYPES[$type]) || preg_match(self::ENUM, $type) === 1; } /** @@ -85,21 +118,49 @@ public static function pattern(string $type): string return self::DEFAULT_PATTERN; } - if (!self::isValidType($type)) { - throw new \InvalidArgumentException( - "Unknown route parameter type [{$type}]. Valid types: " . implode(', ', self::names()) . '.' + if (isset(self::TYPES[$type])) { + return self::TYPES[$type]; + } + + if (preg_match(self::ENUM, $type, $m) === 1) { + $members = array_map( + static fn(string $v): string => preg_quote($v, '#'), + explode('|', $m[1]), ); + + return '(?:' . implode('|', $members) . ')'; } - return self::TYPES[$type]; + throw new \InvalidArgumentException( + "Unknown route parameter type [{$type}]. Valid types: " . implode(', ', self::names()) . '.' + ); } /** * Every placeholder in a path, as [name, type] pairs (type '' when untyped). * + * Kept to exactly this shape — it is public API. Use {@see parseDetailed()} + * when the optional flag matters. + * * @return list */ public static function parse(string $path): array + { + $found = []; + + foreach (self::parseDetailed($path) as $placeholder) { + $found[] = ['name' => $placeholder['name'], 'type' => $placeholder['type']]; + } + + return $found; + } + + /** + * Every placeholder with its optional flag. + * + * @return list + */ + public static function parseDetailed(string $path): array { if (!str_contains($path, '{')) { return []; @@ -110,11 +171,120 @@ public static function parse(string $path): array $found = []; foreach ($matches as $match) { $found[] = [ - 'name' => $match[1], - 'type' => $match[2] ?? '', + 'name' => $match[1], + 'type' => ($match[2] ?? '') !== '' ? $match[2] : '', + 'optional' => ($match[3] ?? '') === '?', ]; } return $found; } + + /** + * The capture-group name for a placeholder. + * + * PCRE group names must be `[A-Za-z_][A-Za-z0-9_]*`, so a declared `{user-id}` + * is folded to `userid`. This sanitisation predates typing and is preserved + * verbatim: the resulting key is what `route_params` has always contained. + */ + public static function groupName(string $declaredName): string + { + return (string) preg_replace('/[^a-zA-Z0-9_]/', '', $declaredName); + } + + /** + * Compile a path template to an anchored regex plus its parameter list. + * + * THE single place a route path becomes a regex — the boot compiler calls it + * to bake the result into the manifest, and RouteMatcher calls it only when + * handed a legacy (un-indexed) manifest. One implementation, so the compiled + * and the on-the-fly paths cannot drift. + * + * Three properties this guarantees that the previous inline compilation did + * not: + * - literal text is preg_quote'd, so `/sitemap.xml/{id}` cannot match + * `/sitemapXxml/1`; + * - the pattern is anchored with `$…#D`, so a trailing newline in the + * request path cannot satisfy `$`; + * - duplicate or PCRE-invalid group names throw here instead of producing + * a pattern that makes preg_match() return false on every request (a + * permanent silent 404). + * + * @return array{regex: string, params: list} + * + * @throws \InvalidArgumentException on an unknown type or an unusable name + */ + public static function compile(string $path): array + { + if (!str_contains($path, '{')) { + return ['regex' => '#^' . preg_quote($path, '#') . '$#D', 'params' => []]; + } + + preg_match_all(self::PLACEHOLDER, $path, $sets, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); + + $regex = ''; + $params = []; + $seen = []; + $offset = 0; + + foreach ($sets as $set) { + [$whole, $start] = $set[0]; + + $declared = $set[1][0]; + + // With PREG_OFFSET_CAPTURE an unmatched group is reported as + // ['', -1] — or omitted entirely when it is trailing — so both the + // group's presence and its offset have to be checked. + $type = isset($set[2]) && $set[2][1] !== -1 ? $set[2][0] : ''; + $optional = isset($set[3]) && $set[3][1] !== -1; + + $name = self::groupName($declared); + + if ($name === '' || !preg_match('/^[A-Za-z_]/', $name)) { + throw new \InvalidArgumentException(sprintf( + 'Route parameter {%s} in [%s] is not a usable capture name. ' + . 'A name must start with a letter or underscore once non-word characters are stripped ' + . '(so {2fa} is invalid; use {twoFactor}).', + $declared, + $path, + )); + } + + if (isset($seen[$name])) { + throw new \InvalidArgumentException(sprintf( + 'Route parameter {%s} in [%s] repeats the capture name [%s]. ' + . 'Each placeholder in a path must be uniquely named — duplicates compile to an ' + . 'invalid pattern that never matches.', + $declared, + $path, + $name, + )); + } + $seen[$name] = true; + + $literal = substr($path, $offset, $start - $offset); + $offset = $start + strlen($whole); + + // An optional parameter swallows the separator in front of it, so + // `/posts/{page?}` matches `/posts` as well as `/posts/7`. + $separator = ''; + if ($optional && $literal !== '' && str_ends_with($literal, '/')) { + $literal = substr($literal, 0, -1); + $separator = '/'; + } + + $group = '(?P<' . $name . '>' . self::pattern($type) . ')'; + + $regex .= preg_quote($literal, '#') + . ($optional ? '(?:' . preg_quote($separator, '#') . $group . ')?' : $group); + + $params[] = ['name' => $name, 'type' => $type, 'optional' => $optional]; + } + + $regex .= preg_quote(substr($path, $offset), '#'); + + // `$…#D` — without the D modifier, `$` also matches immediately before a + // trailing newline, so "/users/1\n" would satisfy "#^/users/{id:num}$#". + return ['regex' => '#^' . $regex . '$#D', 'params' => $params]; + } } diff --git a/src/Kernel/Routing/UrlGenerator.php b/src/Kernel/Routing/UrlGenerator.php index 70a50b1..25598f6 100644 --- a/src/Kernel/Routing/UrlGenerator.php +++ b/src/Kernel/Routing/UrlGenerator.php @@ -45,12 +45,29 @@ */ final class UrlGenerator { + /** + * Matches one placeholder together with the separator in front of it, so an + * omitted optional parameter takes its '/' with it. + */ + private const PLACEHOLDER_WITH_SEPARATOR = '#(/?)\{([^}:?]+)(?::([^}?]+))?(\?)?\}#'; + /** @var array route name => path template */ private array $byName = []; /** @var array route name => HTTP method */ private array $methodByName = []; + /** + * route name => the domain group it belongs to ('' when ungrouped). + * + * Used only for ABSOLUTE urls: a project serving two brands has two routes + * called `vote.home` and `africa.home`, and generating both against a single + * APP_URL would send half its links to the wrong site. + * + * @var array + */ + private array $domainByName = []; + /** * @param array> $manifest compiled route manifest * @param string $base base URL for absolute generation, e.g. https://app.example.com @@ -61,25 +78,45 @@ public function __construct( private readonly string $base = '', private readonly string $secret = '', ) { - foreach ($manifest as $key => $entry) { - $name = $entry['name'] ?? null; - if (!is_string($name) || $name === '') { - continue; - } - [$method, $path] = explode(' ', $key, 2); - $this->byName[$name] = $path; - $this->methodByName[$name] = $method; + foreach (RouteIndex::names($manifest) as $name => $route) { + $this->byName[$name] = $route['path']; + $this->methodByName[$name] = $route['method']; + $this->domainByName[$name] = $route['domain'] ?? ''; } } - /** Build from the compiled manifest on disk. */ + /** + * Build from the compiled manifests on disk. + * + * Prefers `route-names.php` — a name => {path, method} index the boot compiler + * writes. Reading it instead of the full route table matters most where this + * class is actually used: a CLI command or queue worker that mints one + * password-reset link should not hold the application's entire routing + * surface in memory to do it. Falls back to the flat manifest when the index + * is absent (a deploy whose cache predates it). + */ public static function fromManifest(string $base = '', string $secret = ''): self { - return new self( - ManifestReader::readCompiled('route-manifest.php'), - $base, - $secret !== '' ? $secret : (string) (\function_exists('env') ? (env('APP_KEY') ?: '') : ''), - ); + $secret = $secret !== '' + ? $secret + : (string) (\function_exists('env') ? (env('APP_KEY') ?: '') : ''); + + /** @var array $names */ + $names = ManifestReader::readCompiled('route-names.php'); + + if ($names !== []) { + $generator = new self([], $base, $secret); + + foreach ($names as $name => $route) { + $generator->byName[$name] = $route['path'] ?? ''; + $generator->methodByName[$name] = $route['method'] ?? 'GET'; + $generator->domainByName[$name] = $route['domain'] ?? ''; + } + + return $generator; + } + + return new self(ManifestReader::readCompiled('route-manifest.php'), $base, $secret); } public function has(string $name): bool @@ -99,7 +136,8 @@ public function methodFor(string $name): ?string * Parameters not consumed by a path placeholder become the query string, so * `route('search', ['q' => 'x'])` on `/search` yields `/search?q=x`. * - * @param array $parameters + * @param array $parameters a null or '' + * value counts as OMITTED, which is what an optional `{page?}` wants * * @throws \InvalidArgumentException on an unknown name, a missing required * parameter, or a value that violates the placeholder's declared type @@ -121,7 +159,13 @@ public function route(string $name, array $parameters = [], bool $absolute = fal $path .= '?' . http_build_query($remaining); } - return $absolute ? $this->absolute($path) : $path; + return $absolute ? $this->absolute($path, $this->domainByName[$name] ?? '') : $path; + } + + /** The domain group a named route belongs to, or '' when it is ungrouped. */ + public function domainFor(string $name): string + { + return $this->domainByName[$name] ?? ''; } /** @@ -149,7 +193,8 @@ public function to(string $path, array $query = [], bool $absolute = false): str * timestamp) which is covered by the same signature, so the deadline cannot be * extended by editing the URL. * - * @param array $parameters + * @param array $parameters a null or '' + * value counts as OMITTED, which is what an optional `{page?}` wants * @param int|null $expiresIn seconds from now; null = no expiry * * @throws \RuntimeException when no signing secret is configured — failing @@ -170,8 +215,10 @@ public function signedRoute( $url = $this->route($name, $parameters); + // The signature covers the path and query only, never the host, so + // choosing a per-domain base cannot invalidate it. return $absolute - ? $this->absolute($this->appendSignature($url)) + ? $this->absolute($this->appendSignature($url), $this->domainByName[$name] ?? '') : $this->appendSignature($url); } @@ -181,6 +228,11 @@ public function signedRoute( * Accepts a path with query string, e.g. `/verify/7?expires=…&signature=…`. * Pass the path only — a host is not covered by the signature, so including * one would make verification fail behind a proxy that rewrites it. + * + * The query is compared BYTE FOR BYTE with the `signature` pair removed, not + * parsed and re-serialised. `parse_str()` rewrites '.', ' ' and '[' inside + * parameter NAMES, so a legitimately signed URL carrying such a parameter + * could never validate — the check failed closed, but it failed. */ public function hasValidSignature(string $url): bool { @@ -190,20 +242,36 @@ public function hasValidSignature(string $url): bool [$path, $query] = array_pad(explode('?', $url, 2), 2, ''); - parse_str($query, $params); + $signature = null; + $expires = null; + $signed = []; + + foreach ($query === '' ? [] : explode('&', $query) as $pair) { + [$key, $value] = array_pad(explode('=', $pair, 2), 2, ''); + + // Only the FIRST signature pair is lifted out; a second one injected + // by an attacker stays in the signed material and breaks the match. + if ($key === 'signature' && $signature === null) { + $signature = urldecode($value); + continue; + } + + if ($key === 'expires') { + $expires = urldecode($value); + } - $signature = $params['signature'] ?? null; - unset($params['signature']); + $signed[] = $pair; + } - if (!is_string($signature) || $signature === '') { + if ($signature === null || $signature === '') { return false; } - if (isset($params['expires']) && (int) $params['expires'] < time()) { + if ($expires !== null && (int) $expires < time()) { return false; } - $expected = $this->sign($path . ($params === [] ? '' : '?' . http_build_query($params))); + $expected = $this->sign($path . ($signed === [] ? '' : '?' . implode('&', $signed))); // hash_equals — a timing-safe comparison. Never ===. return hash_equals($expected, $signature); @@ -212,51 +280,94 @@ public function hasValidSignature(string $url): bool // ── internals ──────────────────────────────────────────────────────────── /** - * Replace `{name}` / `{name:type}` with values, validating each against its - * declared type. Unconsumed parameters are returned via $remaining. + * Replace `{name}` / `{name:type}` / `{name?}` with values, validating each + * against its declared type. Unconsumed parameters are returned via $remaining. * - * @param array $parameters - * @param array $remaining + * A repeated placeholder (`/a/{id}/b/{id}`) is supported: consumption is + * tracked in a set rather than by removing the value, which previously made + * the second occurrence report a missing parameter. + * + * @param array $parameters a null or '' + * value counts as OMITTED, which is what an optional `{page?}` wants + * @param array $remaining */ private function substitute(string $name, string $template, array $parameters, array &$remaining): string { - $remaining = $parameters; + $consumed = []; $path = preg_replace_callback( - RouteParameter::PLACEHOLDER, - function (array $m) use ($name, &$remaining): string { - $param = $m[1]; - $type = $m[2] ?? ''; + self::PLACEHOLDER_WITH_SEPARATOR, + function (array $m) use ($name, $parameters, &$consumed): string { + $separator = $m[1]; + $param = $m[2]; + $type = ($m[3] ?? '') !== '' ? $m[3] : ''; + $optional = ($m[4] ?? '') === '?'; + + $present = array_key_exists($param, $parameters) + && $parameters[$param] !== null + && $parameters[$param] !== ''; + + if (!$present) { + if ($optional) { + // Takes its separator with it: /posts/{page?} → /posts + $consumed[$param] = true; + + return ''; + } - if (!array_key_exists($param, $remaining)) { throw new \InvalidArgumentException( "Route [{$name}] needs a value for {{$param}}." ); } - $value = (string) $remaining[$param]; - unset($remaining[$param]); + $value = (string) $parameters[$param]; + $consumed[$param] = true; // Generating a URL the matcher cannot match is always a bug. $pattern = RouteParameter::pattern($type); - if (preg_match('#^' . $pattern . '$#', $value) !== 1) { + if (preg_match('#^' . $pattern . '$#D', $value) !== 1) { throw new \InvalidArgumentException( "Value [{$value}] for {{$param}} on route [{$name}] does not satisfy type" . ($type === '' ? ' (a single path segment)' : " [{$type}]") . '.' ); } - return rawurlencode($value); + return $separator . rawurlencode($value); }, $template, ); + $remaining = array_diff_key($parameters, $consumed); + return (string) $path; } - private function absolute(string $path): string + /** + * Prefix a path with the right origin. + * + * A route grouped under a concrete HOST is absolute against THAT host, so a + * two-brand project links each brand to itself instead of sending every link + * to whatever single APP_URL happens to be configured. The scheme is taken + * from the configured base (https when there is none). + * + * A wildcard (`*.example.com`) or a bare subdomain (`api`) names no single + * host — there is nothing to build an origin from — so those fall back to the + * configured base, exactly as an ungrouped route does. + */ + private function absolute(string $path, string $domain = ''): string + { + return rtrim($this->originFor($domain), '/') . $path; + } + + private function originFor(string $domain): string { - return rtrim($this->base, '/') . $path; + if ($domain === '' || str_starts_with($domain, '*.') || !str_contains($domain, '.')) { + return $this->base; + } + + $scheme = $this->base !== '' ? parse_url($this->base, PHP_URL_SCHEME) : null; + + return (is_string($scheme) && $scheme !== '' ? $scheme : 'https') . '://' . $domain; } private function appendSignature(string $url): string diff --git a/src/Kernel/Support/helpers.php b/src/Kernel/Support/helpers.php index 03385da..7b03242 100644 --- a/src/Kernel/Support/helpers.php +++ b/src/Kernel/Support/helpers.php @@ -3,6 +3,7 @@ use AlfacodeTeam\PhpServicePlatform\Kernel\Boot\ManifestReader; use AlfacodeTeam\PhpServicePlatform\Kernel\Config\Repository; +use AlfacodeTeam\PhpServicePlatform\Kernel\Routing\UrlGenerator; use AlfacodeTeam\PhpServicePlatform\Kernel\Support\Paths; use Project\Support\Collection; @@ -85,7 +86,7 @@ function env(string $key, mixed $default = null): mixed return ($value === false || $value === null) ? $default : $value; } -} +} @@ -128,4 +129,58 @@ function collect(iterable $items = []): Collection { return new Collection($items); } +} + +if (!function_exists('url')) { + /** + * The shared URL generator, built from the compiled route-name index. + * + * Named routes exist so that a link SURVIVES a project overriding or moving + * the page it points at — but that only pays off if something actually calls + * the generator, so it gets a helper like config() does. Built once per + * process; the base URL comes from APP_URL (leave it empty and every URL is + * relative, which is what a single-host deployment wants). + */ + function url(): UrlGenerator + { + /** @var UrlGenerator|null $generator */ + static $generator = null; + + return $generator ??= UrlGenerator::fromManifest((string) (env('APP_URL') ?: '')); + } +} + +if (!function_exists('route')) { + /** + * The URL for a NAMED route. + * + * route('user.show', ['id' => 7]); // /users/7 + * route('user.show', ['id' => 7], true); // https://app.test/users/7 + * + * Throws on an unknown name or a value its placeholder type forbids — a + * broken link fails at the call site instead of 404ing in production. + * + * @param array $parameters + */ + function route(string $name, array $parameters = [], bool $absolute = false): string + { + return url()->route($name, $parameters, $absolute); + } +} + +if (!function_exists('signed_route')) { + /** + * A tamper-proof URL for a named route (email verification, one-time actions). + * + * @param array $parameters + * @param int|null $expiresIn seconds from now; null = no expiry + */ + function signed_route( + string $name, + array $parameters = [], + ?int $expiresIn = null, + bool $absolute = false, + ): string { + return url()->signedRoute($name, $parameters, $expiresIn, $absolute); + } } \ No newline at end of file diff --git a/src/System/GlobalKernelProjectScaffolder.php b/src/System/GlobalKernelProjectScaffolder.php index 36062f8..1be8fea 100644 --- a/src/System/GlobalKernelProjectScaffolder.php +++ b/src/System/GlobalKernelProjectScaffolder.php @@ -503,7 +503,14 @@ private function httpEntry(string $projectName): string try { $request = Request::capture(); if ($domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // The FACE (admin/api/project/public) and the HOST let a route declare + // where it exists. Both come from the host DomainResolver already + // VALIDATED against projects.json — never the raw Host header, which + // the client controls and could otherwise pick its own route table. + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } $kernel->http()->handle($request)->send(); } catch (\Throwable $e) { diff --git a/templates/app/bootstrap/app.php b/templates/app/bootstrap/app.php index 76dff05..79072c0 100644 --- a/templates/app/bootstrap/app.php +++ b/templates/app/bootstrap/app.php @@ -83,17 +83,13 @@ // Plugins — module providers (registered into the kernel below). use Plugins\Crypto\Provider as CryptoProvider; use Plugins\Logger\Provider as LoggerProvider; -use Plugins\I18n\Provider as I18nProvider; use Plugins\Database\Provider as DatabaseProvider; use Plugins\Commands\Provider as CommandsProvider; use Plugins\Storage\Provider as StorageProvider; -use Plugins\HttpClient\Provider as HttpClientProvider; use Plugins\Validation\Provider as ValidationProvider; use Plugins\Session\Provider as SessionProvider; use Plugins\Cookie\Provider as CookieProvider; use Plugins\RedisCache\Provider as RedisCacheProvider; -use Plugins\SiteSEO\Application\Listeners\EnqueueIndexNowListener; -use Plugins\SiteSEO\Provider as SiteSeoModule; use Plugins\View\Provider as ViewModule; use Plugins\SecurityFilters\Provider as SecurityFiltersModule; @@ -101,7 +97,7 @@ // Flat layout: this directory's grandparent is the project root. $projectRoot = dirname(__DIR__, 2); -// ----------------------------------------------------------------------------- +// ------------------------------ ----------------------------------------------- // STEP 2 — DOMAIN RESOLUTION // Translate the request's Host header into a DomainContext (project face: // admin / api / project / public + any features). This stays in the project @@ -174,10 +170,10 @@ $ports = [ CachePort::class => static fn(): InMemoryCache => new InMemoryCache(), - DatabasePort::class => static fn(): PdoDatabase => new PdoDatabase( - dsn: $env('DB_DSN', 'sqlite::memory:') ?? 'sqlite::memory:', - username: $env('DB_USERNAME'), - password: $env('DB_PASSWORD'), + DatabasePort::class => static fn(): MultiDriverDatabaseAdapter => + new MultiDriverDatabaseAdapter((new DatabaseConfigurationFactory())->fromEnvironment()), + HashingPort::class => static fn(): PasswordHasher => new PasswordHasher( + cost: (int) ($env('HASH_BCRYPT_COST', '12') ?? '12'), ), HashingPort::class => static fn(): PasswordHasher => new PasswordHasher( cost: (int) ($env('HASH_BCRYPT_COST', '12') ?? '12'), @@ -191,13 +187,6 @@ // when REDIS_HOST is set. Lets `php app/worker/run.php` drain real jobs. QueuePort::class => static fn(): FileQueue => new FileQueue($projectRoot . '/var/queue'), - // The SEO module subscribes EnqueueIndexNowListener to seo.url_published, but - // the EventBus resolves listeners from the CoreContainer — so bind it here - // with the QueuePort. (The factory receives the container.) - EnqueueIndexNowListener::class => static fn($c) => new EnqueueIndexNowListener( - $c->make(QueuePort::class), - ), - // ── When you enable the User + Tenancy plugins ─────────────────────────── // The User plugin subscribes ProvisionTenantProfileListener to user.registered // to write the per-tenant user_profiles row. The EventBus resolves listeners @@ -246,6 +235,14 @@ // the synthetic '__project__' scope — no module register() runs for them. // Keep these controllers thin; real domain logic lives in plugins. ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Route GROUPS from proj.json: a prefix / filters / requires / name + // prefix / SITE stated once for every route inside the group, and + // expanded into flat routes at boot. `site` is part of the route key, + // so one project can answer `GET /` differently per group of hosts. + ->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) + // The hosts this project serves. A route grouped under a domain that is + // not in proj.json "domains" fails the boot — nothing could ever reach it. + ->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) // Project ROUTE POLICY declared in proj.json ("routePolicy": {"disable": []}). // A plugin OWNS its routes, but the project is the final authority: it can @@ -286,10 +283,6 @@ // plus crypto helpers other modules consume. CryptoProvider::class, - // I18n (solves: i18n.translation) — translation/localisation: message catalogues, - // locale negotiation, and the translator used by modules and views. - I18nProvider::class, - // Validation (solves: validation.rules) — the shared request-validation // engine. Its boot() loads config/validation.php and registers the // CommonRules + FinancialRules packs. DTOs extend Plugins\Validation\ @@ -311,20 +304,11 @@ // "requires": ["storage.local"]. StorageProvider::class, - // HttpClient (solves: http.client) — the HttpClientPort for OUTBOUND - // HTTP (calling third-party APIs from gateways). Required by SiteSEO. - HttpClientProvider::class, - // View (solves: view.rendering) — server-side PHP templating: layouts, // sections, the project-first view cascade and `namespace::view` // resolution. Routes opt in via "requires": ["view.rendering"]. ViewModule::class, - // SiteSEO (solves: seo.management) — SEO toolkit: sitemaps, Open Graph, - // JSON-LD, robots, IndexNow. Exposes SeoServiceContract + the /api/seo/* - // routes. Needs http.client (above) for its network actions. - SiteSeoModule::class, - // Edge (solves: edge.routing) — generates this host's web-server front // config from the project's domains: an nginx SNI stream splitter when // nginx+Apache both run, else a plain nginx/Apache vhost (docroot @@ -333,6 +317,17 @@ // CLI: `hkm cli -p edge:status | edge:apply | edge:hosts`. \Plugins\Edge\Provider::class, + // ── Not installed — add when you need them ─────────────────────── + // Each is one command; it fetches the plugin, its dependencies, and + // wires them into this list for you. + // + // hkm plugins install i18n // i18n.translation — __(), locales + // hkm plugins install http-client // http.client — outbound HTTP + // hkm plugins install siteseo // seo.management — sitemaps, JSON-LD + // // (also needs http-client, and a + // // QueuePort-bound EnqueueIndexNowListener + // // in withPorts() for index-on-publish) + // Identity stack (enable together in an app that needs accounts): // \Plugins\User\Provider::class, // user.management (identity + settings) // \Plugins\Feedback\Provider::class, // feedback.management (/ajx/feedback) diff --git a/templates/app/bootstrap/kernel-autoload.php b/templates/app/bootstrap/kernel-autoload.php index 3f10307..3b95735 100644 --- a/templates/app/bootstrap/kernel-autoload.php +++ b/templates/app/bootstrap/kernel-autoload.php @@ -39,7 +39,8 @@ * `composer require` the kernel * locally, this alone is enough and * the steps below are skipped. - * 2. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point + * 2. $HKM_KERNEL_HOME/vendor/autoload.php — the installed kernel. + * 2b. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point * it at any vendor/autoload.php * (e.g. the monorepo's) to reuse a * specific kernel + its plugins. @@ -105,20 +106,41 @@ function psp_require_kernel_autoload(): void $candidates[] = $explicit; } - // (3) Composer's configured home directory, if COMPOSER_HOME is set. + // (3) The installed kernel, via HKM_KERNEL_HOME. + // + // This is how `hkm` installs itself — a system install under + // /opt/hkm-kernel, or a user install under ~/.local/share/hkm/kernel — + // and without it that kernel is invisible to PHP. `hkm run` papered + // over the gap by exporting PSP_GLOBAL_AUTOLOAD for its child, so the + // dev server worked and NOTHING else did: the same project served by + // nginx/PHP-FPM, or a worker started by systemd, or a plain + // `php app/cli/run.php`, died on "Could not load the global kernel + // autoload" with a correctly installed kernel sitting on disk. + $kernelHome = getenv('HKM_KERNEL_HOME'); + if (is_string($kernelHome) && $kernelHome !== '') { + $candidates[] = rtrim($kernelHome, '/\\') . '/vendor/autoload.php'; + } + + // (4) Composer's configured home directory, if COMPOSER_HOME is set. $composerHome = getenv('COMPOSER_HOME'); if (is_string($composerHome) && $composerHome !== '') { $candidates[] = rtrim($composerHome, '/\\') . '/vendor/autoload.php'; } - // (4)+(5) Default global Composer homes on Linux/macOS. + // (5)+(6) Default global Composer homes on Linux/macOS, plus the + // standard `hkm upgrade --user` install path — the one place a kernel + // lands when the operator has no root and never exported anything. $home = getenv('HOME'); if (is_string($home) && $home !== '') { $home = rtrim($home, '/\\'); $candidates[] = $home . '/.config/composer/vendor/autoload.php'; // current default $candidates[] = $home . '/.composer/vendor/autoload.php'; // legacy default + $candidates[] = $home . '/.local/share/hkm/kernel/vendor/autoload.php'; } + // (7) The system install path used by the .deb / install.sh. + $candidates[] = '/opt/hkm-kernel/vendor/autoload.php'; + // Try each candidate; the first one that makes the kernel class // resolvable wins and we return immediately. foreach ($candidates as $autoload) { diff --git a/templates/app/public/index.php b/templates/app/public/index.php index aec43ba..188bc16 100644 --- a/templates/app/public/index.php +++ b/templates/app/public/index.php @@ -46,7 +46,14 @@ // attribute (never via a global — coroutine/Swoole safe). $request = Request::capture(); if (isset($domain) && $domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // The FACE (admin/api/project/public) and the HOST let a route declare + // where it exists. Both come from the host DomainResolver already + // VALIDATED against projects.json — never the raw Host header, which + // the client controls and could otherwise pick its own route table. + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } // Run the HTTP pipeline (security → resolve → load → execute) and emit the diff --git a/templates/app/swoole/index.php b/templates/app/swoole/index.php index dfc7b3f..7688c09 100644 --- a/templates/app/swoole/index.php +++ b/templates/app/swoole/index.php @@ -135,7 +135,12 @@ $hostHeader = $req->header['host'] ?? null; $domain = EntryHelpers::resolveDomain($rootPath, is_string($hostHeader) ? $hostHeader : null); if ($domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // Face + host come from the VALIDATED host (see the FPM entry point + // for why the raw Host header must never select a route table). + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } $response = $kernel->http()->handle($request); diff --git a/templates/plugin/migration_alter.php b/templates/plugin/migration_alter.php new file mode 100644 index 0000000..02ec26d --- /dev/null +++ b/templates/plugin/migration_alter.php @@ -0,0 +1,35 @@ +table('{{LOWER}}', static function ($t) { + // $t->string('widget_id', 64)->nullable(); + // $t->index('widget_id'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->table('{{LOWER}}', static function ($t) { + // $t->dropColumn('widget_id'); + }); + } +}; diff --git a/templates/simple/app/bootstrap/app.php b/templates/simple/app/bootstrap/app.php new file mode 100644 index 0000000..83b667c --- /dev/null +++ b/templates/simple/app/bootstrap/app.php @@ -0,0 +1,216 @@ +http()->handle(...)` for web, `$kernel->cli()->run(...)` for the + * terminal. + * + * ----------------------------------------------------------------------------- + * WHY THIS ONE IS EMPTY + * ----------------------------------------------------------------------------- + * No plugins are enabled. Not "none yet" — none, deliberately. + * + * The framework loads only what a request actually needs, so a plugin you have + * not enabled costs nothing at runtime. It does cost something everywhere else: + * a download, a directory, a line of wiring, a version to keep current, and one + * more thing to understand before you can read your own bootstrap. Starting at + * zero means everything present here is something you asked for. + * + * Add one when a requirement arrives, not in case it does: + * + * hkm plugins install database # DatabasePort, migrations + * hkm plugins install view # PHP templates + * hkm plugins install auth # login, tokens, sessions + * + * `hkm plugins install` fetches the plugin AND the plugins it depends on, wires + * them into this file in dependency order, and publishes their config and + * migrations. `hkm plugins list` shows what is enabled; `hkm plugins domains` + * shows which plugin provides a capability you are looking for. + * + * The full starter (`hkm new `, without --simple) comes with a working + * database, session, cookie, cache, view and validation stack already wired. + * + * ----------------------------------------------------------------------------- + * BOOT ORDER (top to bottom — the order matters) + * ----------------------------------------------------------------------------- + * 1. autoload find the kernel, register the class loaders + * 2. environment load the .env cascade BEFORE anything reads config + * 3. error net catch failures that happen before the kernel is live + * 4. kernel declare paths, routes, security, modules + * 5. build compile manifests and hand the kernel back + */ + +// ----------------------------------------------------------------------------- +// STEP 0 — AUTOLOAD +// kernel-autoload.php only DEFINES the resolver; calling it is what actually +// registers the kernel's class loaders. Requiring the file and forgetting the +// call leaves every framework class undefined, and the failure surfaces on the +// first one used rather than here. +// ----------------------------------------------------------------------------- +if (!function_exists('psp_require_kernel_autoload') || !function_exists('psp_kernel_home')) { + require_once __DIR__ . '/kernel-autoload.php'; +} +psp_require_kernel_autoload(); + +use AlfacodeTeam\PhpServicePlatform\Kernel\Kernel; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer; + +use Project\Bootstrap\EntryHelpers; +use Project\Infrastructure\FileCache; +use Project\Infrastructure\LazyDatabasePort; +use Project\Infrastructure\PdoDatabase; +use Project\Bootstrap\Environment\ErrorGuard; +use Project\Bootstrap\Environment\LoadEnvironment; + +// ----------------------------------------------------------------------------- +// STEP 1 — PATHS +// Flat layout: the scaffolded directory IS the project, so this file's +// grandparent (bootstrap → app → root) is the project root. +// ----------------------------------------------------------------------------- +$projectRoot = dirname(__DIR__, 2); + +// ----------------------------------------------------------------------------- +// STEP 2 — DOMAIN RESOLUTION +// Turn the request's Host header into a DomainContext (which project face is +// being served, and its features). Null under CLI and workers — no Host header +// there, which is expected and handled downstream. +// ----------------------------------------------------------------------------- +$domain = EntryHelpers::resolveDomain($projectRoot, $_SERVER['HTTP_HOST'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 3 — ENVIRONMENT +// Load .env before anything reads configuration. Real process environment +// always wins, so server config is never clobbered by a file. +// +// Values land in $_ENV/$_SERVER and NOT in putenv(), so read them with the +// env() helper — getenv() will not see them. +// ----------------------------------------------------------------------------- +LoadEnvironment::load($projectRoot, $domain, $_SERVER['argv'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 4 — PRE-KERNEL ERROR NET +// The outer safety net, for failures the kernel's own error pipeline cannot +// catch because it is not running yet: parse errors, fatals, out-of-memory. +// Writes to the same log the kernel uses, so everything lands in one file. +// ----------------------------------------------------------------------------- +ErrorGuard::install($projectRoot . '/var/logs/errors.log'); + +// ----------------------------------------------------------------------------- +// STEP 5 — THE KERNEL +// ----------------------------------------------------------------------------- +return Kernel::configure() + + // Where things live. Flat layout, so both are the project root. + ->withBasePath($projectRoot) + ->withProjectPath($projectRoot) + + // ------------------------------------------------------------------------- + // PORTS + // ------------------------------------------------------------------------- + // The kernel requires a DatabasePort and a CachePort to be bound before it + // will boot. These two are the kernel's OWN implementations — no plugin + // involved — so an empty project starts and serves immediately. + // + // Both are deliberately modest, and both are meant to be replaced: + // + // hkm plugins install database // pooled multi-driver adapter + // hkm plugins install redis-cache // Redis CachePort + QueuePort + // + // Installing either one rewrites the binding below to use it. + ->withPorts([ + // Lazy: the closure runs on FIRST USE, not at boot. A project with no + // database configured therefore boots and serves normally, and only a + // request that actually touches the database pays for a connection — + // or fails, which is the honest moment to find out DB_DSN is unset. + DatabasePort::class => new LazyDatabasePort( + static fn (): PdoDatabase => new PdoDatabase( + env('DB_DSN', 'sqlite:' . $projectRoot . '/var/database.sqlite'), + env('DB_USERNAME'), + env('DB_PASSWORD'), + ), + ), + + // File-backed, so a cached value survives between requests under + // PHP-FPM (an in-memory cache would not — each request is a new + // process, and every read would miss). + CachePort::class => new FileCache($projectRoot . '/var/cache/data'), + ]) + + // Routes come from proj.json — never from PHP. Declaring them as data is + // what lets the kernel compile a route manifest at build time and resolve a + // request without loading a single module. + ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Route GROUPS from proj.json: a prefix / filters / requires / name + // prefix / SITE stated once for every route inside the group, and + // expanded into flat routes at boot. `site` is part of the route key, + // so one project can answer `GET /` differently per group of hosts. + ->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) + // The hosts this project serves. A route grouped under a domain that is + // not in proj.json "domains" fails the boot — nothing could ever reach it. + ->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) + + // A project can also switch OFF a route a plugin declares, without forking + // the plugin: proj.json "routePolicy": { "disable": ["GET /register"] }. + ->withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot)) + + ->withSecurity([ + // The only security layer the kernel ships: stateless HMAC-signed CSRF + // tokens. Nothing is stored and no cookie value is trusted as the + // token, so cookie injection cannot bypass it. + // + // The secret defaults to APP_KEY. An EMPTY APP_KEY fails closed — every + // state-changing request is denied — so set one before serving traffic: + // hkm key:generate + new CsrfTokenLayer( + headerName: 'X-CSRF-Token', + formField: '_csrf_token', + lifetime: 43200, // 12 hours, in seconds + // Paths that never carry a browser session; APIs authenticate with + // a token instead, for which CSRF is meaningless. + exemptPaths: ['/api'], + ), + + // Authentication is NOT here. The kernel ships no token validator on + // purpose — add the Auth plugin and its layers when you need accounts: + // hkm plugins install auth + ]) + + // ------------------------------------------------------------------------- + // MODULES + // ------------------------------------------------------------------------- + // Empty, and that is the point of --simple. `hkm plugins install ` + // adds entries here for you, in dependency order, with a comment saying + // what each one solves. + // + // A module listed here is loaded ON DEMAND: only when a route being served + // needs it. Listing one costs nothing until something asks for it. + ->withModules([ + // + ]) + + // ------------------------------------------------------------------------- + // ESSENTIAL MODULES + // ------------------------------------------------------------------------- + // Registered into EVERY request, needed or not. Reserve this for + // cross-cutting request-scoped infrastructure (sessions, cookies) that + // cannot be an app-lifetime port — and keep the list short, because each + // entry and its whole dependency graph registers on every single request. + // + // Read from proj.json "essentials": [...], so which plugins are global is a + // deployment decision rather than a code edit. + ->withEssentialModules(EntryHelpers::projectEssentials($projectRoot)) + + // Compile-only: this validates config and compiles the manifests. The + // entry point materializes the kernel on its first http()/cli() call. + ->build(); diff --git a/tests/Fixtures/PrefixedModule/Provider.php b/tests/Fixtures/PrefixedModule/Provider.php new file mode 100644 index 0000000..83cf7b9 --- /dev/null +++ b/tests/Fixtures/PrefixedModule/Provider.php @@ -0,0 +1,16 @@ +root = sys_get_temp_dir() . '/hkm-bootstamp-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + mkdir($this->root . '/config', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + + $this->previousEnv = $_ENV['BOOT_CACHE'] ?? false; + + // A compiled manifest must exist for a cached boot to be usable at all. + ManifestWriter::write('route-manifest.php', ['GET /' => ['handler' => 'C@m']]); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + if ($this->previousEnv === false) { + unset($_ENV['BOOT_CACHE']); + } else { + $_ENV['BOOT_CACHE'] = $this->previousEnv; + } + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + foreach (glob($this->root . '/config/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/config'); + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root . '/var'); + @rmdir($this->root); + } + + private function hash(mixed $inputs = ['modules' => ['A']]): string + { + return BootStamp::hash(is_array($inputs) ? $inputs : [$inputs]); + } + + // ── The flag ──────────────────────────────────────────────────────────── + + public function test_it_is_off_unless_explicitly_enabled(): void + { + unset($_ENV['BOOT_CACHE']); + self::assertFalse(BootStamp::enabled()); + + $_ENV['BOOT_CACHE'] = ''; + self::assertFalse(BootStamp::enabled()); + + $_ENV['BOOT_CACHE'] = '0'; + self::assertFalse(BootStamp::enabled(), 'a falsy value must not enable it'); + } + + public function test_it_is_on_for_a_truthy_value(): void + { + foreach (['1', 'true', 'on', 'yes'] as $value) { + $_ENV['BOOT_CACHE'] = $value; + self::assertTrue(BootStamp::enabled(), "[{$value}] should enable the cache"); + } + } + + // ── Hit ───────────────────────────────────────────────────────────────── + + public function test_a_fresh_stamp_is_a_hit_and_returns_the_cached_essentials(): void + { + BootStamp::write($this->hash(), [], ['App\\SessionProvider']); + + $cached = BootStamp::read($this->hash()); + + self::assertNotNull($cached); + // Recomputing these means re-reading every module.json — the exact cost + // the cache exists to avoid — so they ride along with it. + self::assertSame(['App\\SessionProvider'], $cached['essentials']); + } + + public function test_no_stamp_at_all_is_a_miss(): void + { + self::assertNull(BootStamp::read($this->hash())); + } + + // ── Every way it must MISS ────────────────────────────────────────────── + + public function test_changed_builder_inputs_miss(): void + { + // proj.json and bootstrap/app.php reach the kernel as PHP arrays, so this + // covers edits to both without stat'ing either. + BootStamp::write($this->hash(['modules' => ['A']]), [], []); + + self::assertNull(BootStamp::read($this->hash(['modules' => ['A', 'B']]))); + } + + public function test_a_modified_source_file_misses(): void + { + $file = $this->root . '/module.json'; + file_put_contents($file, '{"solves":"a"}'); + + BootStamp::write($this->hash(), [$file], []); + self::assertNotNull(BootStamp::read($this->hash())); + + file_put_contents($file, '{"solves":"a","routes":[]}'); + clearstatcache(); + + self::assertNull(BootStamp::read($this->hash()), 'size changed'); + } + + public function test_a_deleted_source_file_misses(): void + { + $file = $this->root . '/module.json'; + file_put_contents($file, '{"solves":"a"}'); + BootStamp::write($this->hash(), [$file], []); + + unlink($file); + clearstatcache(); + + self::assertNull(BootStamp::read($this->hash())); + } + + public function test_a_modified_config_file_misses(): void + { + file_put_contents($this->root . '/config/mail.php', ' "a"];'); + BootStamp::write($this->hash(), [], []); + self::assertNotNull(BootStamp::read($this->hash())); + + file_put_contents($this->root . '/config/mail.php', ' "bbbbb"];'); + clearstatcache(); + + self::assertNull(BootStamp::read($this->hash())); + } + + public function test_an_ADDED_config_file_misses(): void + { + // The subtle one: a new file appears in no recorded entry, so only the + // per-directory count catches it. + file_put_contents($this->root . '/config/mail.php', 'hash(), [], []); + self::assertNotNull(BootStamp::read($this->hash())); + + file_put_contents($this->root . '/config/queue.php', 'hash())); + } + + public function test_a_REMOVED_config_file_misses(): void + { + file_put_contents($this->root . '/config/mail.php', 'root . '/config/queue.php', 'hash(), [], []); + + unlink($this->root . '/config/queue.php'); + clearstatcache(); + + self::assertNull(BootStamp::read($this->hash())); + } + + public function test_a_missing_compiled_manifest_misses(): void + { + // The stamp may be pristine while the manifests it vouches for were + // cleared by a deploy. Never serve from a cache with nothing behind it. + BootStamp::write($this->hash(), [], []); + unlink(Paths::cache('manifests/route-manifest.php')); + + self::assertNull(BootStamp::read($this->hash())); + } + + public function test_a_plugin_config_directory_beside_a_module_json_is_watched(): void + { + // BootStamp derives each plugin's config/ from where its module.json is, + // because that is what CompileConfigManifestStage globs. + mkdir($this->root . '/plugin/config', 0775, true); + $module = $this->root . '/plugin/module.json'; + file_put_contents($module, '{"solves":"a"}'); + file_put_contents($this->root . '/plugin/config/thing.php', 'hash(), [$module], []); + self::assertNotNull(BootStamp::read($this->hash())); + + file_put_contents($this->root . '/plugin/config/thing.php', ' true];'); + clearstatcache(); + self::assertNull(BootStamp::read($this->hash())); + + @unlink($this->root . '/plugin/config/thing.php'); + @unlink($module); + @rmdir($this->root . '/plugin/config'); + @rmdir($this->root . '/plugin'); + } +} diff --git a/tests/Unit/Kernel/Boot/RouteCompilationTest.php b/tests/Unit/Kernel/Boot/RouteCompilationTest.php new file mode 100644 index 0000000..6401bb2 --- /dev/null +++ b/tests/Unit/Kernel/Boot/RouteCompilationTest.php @@ -0,0 +1,281 @@ +root = sys_get_temp_dir() . '/hkm-routecompile-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root . '/var'); + @rmdir($this->root); + } + + /** + * @param list> $projectRoutes + * @param list $modules + * @param list $disable + */ + private function compile(array $projectRoutes = [], array $modules = [], array $disable = []): void + { + (new CompileRouteManifestStage( + $modules, + projectRoutes: $projectRoutes, + disabledRoutes: $disable, + reader: new ManifestReader(), + ))->run(); + } + + /** @return array */ + private function manifest(string $file = 'route-manifest.php'): array + { + return ManifestReader::readCompiled($file); + } + + // ── Precompilation ────────────────────────────────────────────────────── + + public function test_the_handler_split_is_baked_into_the_entry(): void + { + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show']]); + + $entry = $this->manifest()['GET /x']; + self::assertSame('App\\C', $entry['class']); + self::assertSame('show', $entry['action']); + self::assertSame('App\\C@show', $entry['handler'], 'the original stays for existing readers'); + } + + public function test_filter_specs_are_parsed_at_boot(): void + { + $this->compile([[ + 'method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show', + 'filters' => ['auth', 'throttle:60,1'], + ]]); + + $entry = $this->manifest()['GET /x']; + + self::assertSame(['auth', 'throttle:60,1'], $entry['filters'], 'raw specs are preserved'); + self::assertSame( + [ + ['alias' => 'auth', 'args' => []], + ['alias' => 'throttle', 'args' => ['60', '1']], + ], + $entry['filter_specs'], + ); + } + + public function test_the_dependency_graph_key_is_precomputed(): void + { + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show']]); + + self::assertSame('__project__|', $this->manifest()['GET /x']['graph_key']); + } + + public function test_a_dynamic_route_carries_its_compiled_regex(): void + { + $this->compile([['method' => 'GET', 'path' => '/u/{id:num}', 'handler' => 'App\\C@show']]); + + $entry = $this->manifest()['GET /u/{id:num}']; + + self::assertStringEndsWith('$#D', $entry['regex'], 'anchored with the D modifier'); + self::assertSame([['name' => 'id', 'type' => 'num', 'optional' => false]], $entry['params']); + } + + public function test_a_static_route_carries_no_regex(): void + { + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show']]); + + self::assertArrayNotHasKey('regex', $this->manifest()['GET /x']); + } + + // ── The derived manifests ─────────────────────────────────────────────── + + public function test_the_matcher_index_is_written_alongside_the_manifest(): void + { + $this->compile([ + ['method' => 'GET', 'path' => '/health', 'handler' => 'App\\C@up'], + ['method' => 'GET', 'path' => '/u/{id}', 'handler' => 'App\\C@show'], + ['method' => 'GET', 'path' => '/{slug}', 'handler' => 'App\\C@page'], + ]); + + $index = $this->manifest('route-index.php'); + + // '' is the shared site every unscoped route lives in. + self::assertArrayHasKey('GET /health', $index['static']['']); + self::assertArrayHasKey('u', $index['dynamic']['']['GET']['buckets']); + self::assertCount(1, $index['dynamic']['']['GET']['wild']); + self::assertSame(['GET'], $index['methods']); + self::assertSame([], $index['domains']); + } + + public function test_the_name_index_is_written_alongside_the_manifest(): void + { + $this->compile([ + ['method' => 'GET', 'path' => '/u/{id}', 'handler' => 'App\\C@show', 'name' => 'user.show'], + ['method' => 'POST', 'path' => '/u', 'handler' => 'App\\C@store'], + ]); + + self::assertSame( + ['user.show' => ['path' => '/u/{id}', 'method' => 'GET', 'domain' => '']], + $this->manifest('route-names.php'), + ); + } + + // ── Module-level declarations ─────────────────────────────────────────── + + public function test_a_module_route_prefix_is_applied_to_every_route(): void + { + $this->compile(modules: [PrefixedProvider::class]); + + $manifest = $this->manifest(); + + self::assertArrayHasKey('GET /api/v1/things', $manifest); + self::assertArrayHasKey('POST /api/v1/things', $manifest); + self::assertArrayHasKey('GET /api/v1/things/{id:num}', $manifest); + } + + public function test_module_default_filters_are_merged_in_front(): void + { + $this->compile(modules: [PrefixedProvider::class]); + + self::assertSame( + ['auth', 'throttle:60,1'], + $this->manifest()['GET /api/v1/things']['filters'], + ); + } + + public function test_a_route_overrides_a_module_default_of_the_same_alias(): void + { + $this->compile(modules: [PrefixedProvider::class]); + + // The route declares throttle:5,1 — it must replace the module's 60,1 + // rather than run the throttle stage twice with different budgets. + self::assertSame( + ['auth', 'throttle:5,1'], + $this->manifest()['POST /api/v1/things']['filters'], + ); + } + + public function test_a_prefixed_route_keeps_its_name(): void + { + $this->compile(modules: [PrefixedProvider::class]); + + self::assertSame( + ['things.index' => ['path' => '/api/v1/things', 'method' => 'GET', 'domain' => '']], + $this->manifest('route-names.php'), + ); + } + + // ── Validation: each of these used to compile to a dead route ─────────── + + public function test_a_path_without_a_leading_slash_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/does not start with/'); + + $this->compile([['method' => 'GET', 'path' => 'users', 'handler' => 'App\\C@show']]); + } + + public function test_a_duplicate_parameter_name_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/repeats the capture name/'); + + $this->compile([['method' => 'GET', 'path' => '/a/{id}/b/{id}', 'handler' => 'App\\C@show']]); + } + + public function test_a_parameter_name_pcre_rejects_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/not a usable capture name/'); + + $this->compile([['method' => 'GET', 'path' => '/{2fa}', 'handler' => 'App\\C@show']]); + } + + public function test_a_handler_without_a_separator_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches("/'Controller@method' format/"); + + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C']]); + } + + public function test_a_handler_with_two_separators_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches("/'Controller@method' format/"); + + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@a@b']]); + } + + public function test_a_non_string_filter_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/not a string/'); + + $this->compile([[ + 'method' => 'GET', 'path' => '/x', 'handler' => 'App\\C@show', + 'filters' => [['auth']], + ]]); + } + + // ── Compatibility: a name that sanitises to something valid still works ── + + public function test_a_hyphenated_parameter_name_still_compiles(): void + { + // Sanitisation to 'userid' predates typing; tightening the grammar here + // would silently kill routes that work today. + $this->compile([['method' => 'GET', 'path' => '/u/{user-id}', 'handler' => 'App\\C@show']]); + + self::assertSame( + [['name' => 'userid', 'type' => '', 'optional' => false]], + $this->manifest()['GET /u/{user-id}']['params'], + ); + } + + public function test_handler_verification_is_off_by_default(): void + { + // A controller class that does not exist must NOT fail the build unless + // ROUTE_VERIFY_HANDLERS is explicitly enabled. + $this->compile([['method' => 'GET', 'path' => '/x', 'handler' => 'No\\Such\\Controller@show']]); + + self::assertArrayHasKey('GET /x', $this->manifest()); + } +} diff --git a/tests/Unit/Kernel/Boot/RouteGroupTest.php b/tests/Unit/Kernel/Boot/RouteGroupTest.php new file mode 100644 index 0000000..dbb4ec4 --- /dev/null +++ b/tests/Unit/Kernel/Boot/RouteGroupTest.php @@ -0,0 +1,582 @@ +root = sys_get_temp_dir() . '/hkm-routegroup-' . bin2hex(random_bytes(6)); + mkdir($this->root . '/var/cache/manifests', 0775, true); + + $this->previousProject = Paths::project(); + Paths::setBase($this->root); + Paths::setProject($this->root); + } + + protected function tearDown(): void + { + Paths::setProject($this->previousProject); + + foreach (glob($this->root . '/var/cache/manifests/*') ?: [] as $f) { + @unlink($f); + } + @rmdir($this->root . '/var/cache/manifests'); + @rmdir($this->root . '/var/cache'); + @rmdir($this->root . '/var'); + @rmdir($this->root); + } + + /** + * @param array $groups + * @param list> $projectRoutes + * @param list $disable + * @param list $domains + */ + private function compile( + array $groups = [], + array $projectRoutes = [], + array $disable = [], + array $domains = [], + ): void { + (new CompileRouteManifestStage( + [], + projectRoutes: $projectRoutes, + disabledRoutes: $disable, + reader: new ManifestReader(), + projectGroups: $groups, + projectDomains: $domains, + ))->run(); + } + + /** @return array */ + private function manifest(string $file = 'route-manifest.php'): array + { + return ManifestReader::readCompiled($file); + } + + private function matcher(): RouteMatcher + { + return RouteMatcher::fromCompiled($this->manifest('route-index.php')); + } + + /** @return array{entry: array, params: array}|null */ + private function matchHost(string $path, string $host): ?array + { + return $this->matcher()->match('GET', $path, RouteIndex::hostCandidates($host)); + } + + // ── Key format ────────────────────────────────────────────────────────── + + public function test_an_ungrouped_route_key_is_unchanged(): void + { + self::assertSame('GET /x', RouteIndex::key('GET', '', '/x')); + self::assertSame( + ['method' => 'GET', 'domain' => '', 'path' => '/x'], + RouteIndex::parseKey('GET /x'), + ); + } + + public function test_a_grouped_key_keeps_the_path_parseable_by_an_older_reader(): void + { + $key = RouteIndex::key('GET', 'africavoting.local', '/dash'); + + self::assertSame('GET@africavoting.local /dash', $key); + + // The decisive property: a consumer that splits on the first space and + // has never heard of domain groups still gets a clean, leading-slash + // path, and sees a method no HTTP verb matches — so it SKIPS rather than + // emitting a corrupted URL. + [$verb, $path] = explode(' ', $key, 2); + self::assertSame('/dash', $path); + self::assertNotSame('GET', strtoupper($verb)); + } + + // ── Grouping ──────────────────────────────────────────────────────────── + + public function test_a_group_applies_its_prefix_filters_and_name(): void + { + $this->compile([ + 'groups' => [[ + 'prefix' => '/admin', + 'filters' => ['auth'], + 'name' => 'admin.', + 'routes' => [ + ['method' => 'GET', 'path' => '/users', 'handler' => 'A\\C@index', 'name' => 'users'], + ], + ]], + ]); + + $entry = $this->manifest()['GET /admin/users']; + + self::assertSame(['auth'], $entry['filters']); + self::assertSame('admin.users', $entry['name']); + } + + public function test_groups_nest_and_accumulate(): void + { + $this->compile([ + 'routePrefix' => '/api', + 'groups' => [[ + 'prefix' => '/v1', + 'filters' => ['auth'], + 'name' => 'api.', + 'groups' => [[ + 'prefix' => '/admin', + 'filters' => ['shield'], + 'name' => 'admin.', + 'routes' => [ + ['method' => 'GET', 'path' => '/stats', 'handler' => 'A\\C@stats', 'name' => 'stats'], + ], + ]], + ]], + ]); + + $entry = $this->manifest()['GET /api/v1/admin/stats']; + + self::assertSame(['auth', 'shield'], $entry['filters']); + self::assertSame('api.admin.stats', $entry['name']); + } + + public function test_an_inner_declaration_overrides_an_outer_filter_of_the_same_alias(): void + { + $this->compile([ + 'routeFilters' => ['throttle:60,1'], + 'groups' => [[ + 'routes' => [ + ['method' => 'GET', 'path' => '/burst', 'handler' => 'A\\C@x', 'filters' => ['throttle:5,1']], + ], + ]], + ]); + + // Replaced, not doubled — running the throttle stage twice with two + // different budgets is never what was meant. + self::assertSame(['throttle:5,1'], $this->manifest()['GET /burst']['filters']); + } + + public function test_an_unnamed_route_stays_unnamed_inside_a_named_group(): void + { + // A group's name is a PREFIX for routes that opted into a name; it does + // not invent names for routes that never asked for one. + $this->compile([ + 'groups' => [[ + 'name' => 'admin.', + 'routes' => [['method' => 'GET', 'path' => '/a', 'handler' => 'A\\C@a']], + ]], + ]); + + self::assertNull($this->manifest()['GET /a']['name']); + self::assertSame([], $this->manifest('route-names.php')); + } + + public function test_routes_declared_alongside_groups_are_not_dropped(): void + { + // withRoutes() routes and a routes[] passed to withRouteGroups() are BOTH + // the project's, so they concatenate. This was an array union, which keeps + // the LEFT key — so the second list vanished without a word. + $this->compile( + ['routePrefix' => '/api/v2', 'routes' => [ + ['method' => 'GET', 'path' => '/ping', 'handler' => 'A\\C@ping'], + ]], + [['method' => 'GET', 'path' => '/direct', 'handler' => 'A\\C@direct']], + ); + + $manifest = $this->manifest(); + + self::assertArrayHasKey('GET /api/v2/ping', $manifest, 'routes[] beside groups must survive'); + self::assertArrayHasKey('GET /api/v2/direct', $manifest, 'withRoutes() routes must survive'); + } + + public function test_a_route_replaces_a_module_wide_filter_of_the_same_alias(): void + { + $this->compile([ + 'routeFilters' => ['throttle:60,1'], + 'routes' => [ + ['method' => 'GET', 'path' => '/ping', 'handler' => 'A\\C@ping'], + ['method' => 'POST', 'path' => '/import', 'handler' => 'A\\C@import', + 'filters' => ['auth', 'throttle:5,1']], + ], + ]); + + $manifest = $this->manifest(); + + self::assertSame(['throttle:60,1'], $manifest['GET /ping']['filters']); + self::assertSame(['auth', 'throttle:5,1'], $manifest['POST /import']['filters']); + } + + public function test_runaway_group_nesting_fails_the_boot(): void + { + $group = ['routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\C@x']]]; + for ($i = 0; $i < 20; $i++) { + $group = ['groups' => [$group]]; + } + + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/nest more than/'); + + $this->compile($group); + } + + // ── The compiler GROUPS, it does not VERIFY ───────────────────────────── + + public function test_the_domain_is_taken_verbatim(): void + { + $this->compile([ + 'groups' => [ + ['domain' => 'africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Af@home']]], + ['domain' => '*.africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/w', 'handler' => 'A\\W@home']]], + ['subdomain' => 'organizer', 'routes' => [['method' => 'GET', 'path' => '/o', 'handler' => 'A\\Or@home']]], + ], + ]); + + $manifest = $this->manifest(); + + self::assertArrayHasKey('GET@africavoting.local /', $manifest); + self::assertArrayHasKey('GET@*.africavoting.local /w', $manifest); + self::assertArrayHasKey('GET@organizer /o', $manifest); + } + + public function test_a_domain_this_deployment_does_not_serve_still_compiles(): void + { + // Nothing is resolved or checked. A domain nothing requests simply never + // matches — exactly like a path nothing requests. + $this->compile([ + 'groups' => [['domain' => 'not-a-host-we-serve.example', 'routes' => [ + ['method' => 'GET', 'path' => '/x', 'handler' => 'A\\C@x'], + ]]], + ]); + + self::assertArrayHasKey('GET@not-a-host-we-serve.example /x', $this->manifest()); + } + + public function test_the_domain_is_lower_cased(): void + { + $this->compile([ + 'groups' => [['domain' => ' AfricaVoting.LOCAL ', 'routes' => [ + ['method' => 'GET', 'path' => '/x', 'handler' => 'A\\C@x'], + ]]], + ]); + + self::assertArrayHasKey('GET@africavoting.local /x', $this->manifest()); + } + + // ── Host → group matching ─────────────────────────────────────────────── + + public function test_host_candidates_are_ordered_most_specific_first(): void + { + self::assertSame( + ['organizer.africavoting.local', '*.africavoting.local', '*.local', 'organizer'], + RouteIndex::hostCandidates('organizer.africavoting.local'), + ); + + // No subdomain to speak of, so no bare label. + self::assertSame(['hkmvote.local', '*.local'], RouteIndex::hostCandidates('hkmvote.local')); + self::assertSame(['localhost'], RouteIndex::hostCandidates('localhost')); + self::assertSame([], RouteIndex::hostCandidates('')); + } + + public function test_a_port_and_case_are_stripped_from_the_request_host(): void + { + self::assertSame(['hkmvote.local', '*.local'], RouteIndex::hostCandidates('HKMVote.local:8443')); + } + + public function test_two_domains_may_declare_the_same_path(): void + { + $this->compile([ + 'groups' => [ + ['domain' => 'hkmvote.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Vote@home']]], + ['domain' => 'africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Africa@home']]], + ], + ]); + + // As one key these would have been a duplicate-route boot failure, and + // `faces` could only have hidden one of them. + self::assertSame('A\\Vote@home', $this->matchHost('/', 'hkmvote.local')['entry']['handler']); + self::assertSame('A\\Africa@home', $this->matchHost('/', 'africavoting.local')['entry']['handler']); + self::assertNull($this->matchHost('/', 'unknown.example')); + } + + public function test_a_bare_subdomain_group_matches_that_subdomain_on_any_host(): void + { + $this->compile([ + 'groups' => [['subdomain' => 'organizer', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\Org@home'], + ]]], + ]); + + self::assertSame('A\\Org@home', $this->matchHost('/', 'organizer.africavoting.local')['entry']['handler']); + self::assertSame('A\\Org@home', $this->matchHost('/', 'organizer.hkmvote.local')['entry']['handler']); + self::assertNull($this->matchHost('/', 'app.hkmvote.local')); + } + + public function test_a_wildcard_group_matches_any_subdomain_of_its_parent(): void + { + $this->compile([ + 'groups' => [['domain' => '*.africavoting.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\Wild@home'], + ]]], + ]); + + self::assertSame('A\\Wild@home', $this->matchHost('/', 'news.africavoting.local')['entry']['handler']); + self::assertNull($this->matchHost('/', 'africavoting.local'), 'the apex is not a subdomain of itself'); + } + + public function test_an_exact_host_beats_a_wildcard_which_beats_a_bare_subdomain(): void + { + $this->compile([ + 'groups' => [ + ['subdomain' => 'organizer', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Sub@home']]], + ['domain' => '*.africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Wild@home']]], + ['domain' => 'organizer.africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\Exact@home']]], + ], + ]); + + // All three could match; specificity decides, not declaration order. + self::assertSame('A\\Exact@home', $this->matchHost('/', 'organizer.africavoting.local')['entry']['handler']); + self::assertSame('A\\Wild@home', $this->matchHost('/', 'news.africavoting.local')['entry']['handler']); + self::assertSame('A\\Sub@home', $this->matchHost('/', 'organizer.hkmvote.local')['entry']['handler']); + } + + public function test_a_grouped_route_overrides_the_shared_one_on_that_host_only(): void + { + $this->compile( + ['groups' => [['subdomain' => 'organizer', 'routes' => [ + ['method' => 'GET', 'path' => '/dashboard', 'handler' => 'A\\Organizer@dash'], + ]]]], + [['method' => 'GET', 'path' => '/dashboard', 'handler' => 'A\\Shared@dash']], + ); + + self::assertSame('A\\Organizer@dash', $this->matchHost('/dashboard', 'organizer.hkmvote.local')['entry']['handler']); + self::assertSame('A\\Shared@dash', $this->matchHost('/dashboard', 'app.hkmvote.local')['entry']['handler']); + self::assertSame('A\\Shared@dash', $this->matcher()->match('GET', '/dashboard')['entry']['handler']); + } + + public function test_a_shared_static_route_still_beats_a_grouped_dynamic_one(): void + { + // Static-beats-dynamic is an invariant. Searching a domain group + // end-to-end first would let its /users/{id} swallow the shared literal + // /users/me. + $this->compile( + ['groups' => [['domain' => 'hkmvote.local', 'routes' => [ + ['method' => 'GET', 'path' => '/users/{id}', 'handler' => 'A\\Vote@show'], + ]]]], + [['method' => 'GET', 'path' => '/users/me', 'handler' => 'A\\Shared@me']], + ); + + self::assertSame('A\\Shared@me', $this->matchHost('/users/me', 'hkmvote.local')['entry']['handler']); + self::assertSame('A\\Vote@show', $this->matchHost('/users/7', 'hkmvote.local')['entry']['handler']); + } + + public function test_the_index_reports_which_domains_exist(): void + { + $this->compile([ + 'groups' => [['domain' => 'hkmvote.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]], + ]); + + self::assertSame(['hkmvote.local'], $this->manifest('route-index.php')['domains']); + } + + public function test_an_ungrouped_application_is_unaffected(): void + { + $this->compile([], [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\C@x']]); + + self::assertSame([], $this->manifest('route-index.php')['domains']); + self::assertNotNull($this->matcher()->match('GET', '/x')); + self::assertNotNull($this->matchHost('/x', 'anything.example')); + } + + // ── Checked against the hosts the project actually serves ─────────────── + + public function test_a_registered_domain_compiles(): void + { + $this->compile( + ['groups' => [['domain' => 'africavoting.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['hkmvote.local', 'africavoting.local'], + ); + + self::assertArrayHasKey('GET@africavoting.local /', $this->manifest()); + } + + public function test_an_unregistered_domain_fails_the_boot(): void + { + // Nothing could ever reach it: a request for that host would have been + // routed to a different project, or refused, before the router ran. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/this project does not serve/'); + + $this->compile( + ['groups' => [['domain' => 'typo.africavotng.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['hkmvote.local', 'africavoting.local'], + ); + } + + public function test_the_failure_lists_the_registered_domains(): void + { + try { + $this->compile( + ['groups' => [['domain' => 'nope.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['hkmvote.local'], + ); + self::fail('expected a BootException'); + } catch (BootException $e) { + self::assertStringContainsString('hkmvote.local', $e->getMessage()); + self::assertStringContainsString('subdomain', $e->getMessage(), 'points at the escape hatch'); + } + } + + public function test_a_wildcard_passes_when_its_parent_is_registered(): void + { + // The right tool for tenant hosts, which land in the database rather + // than in proj.json. + $this->compile( + ['groups' => [['domain' => '*.africavoting.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['africavoting.local'], + ); + + self::assertArrayHasKey('GET@*.africavoting.local /', $this->manifest()); + } + + public function test_a_wildcard_passes_when_a_registered_host_falls_under_it(): void + { + $this->compile( + ['groups' => [['domain' => '*.africavoting.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['organizer.africavoting.local'], + ); + + self::assertArrayHasKey('GET@*.africavoting.local /', $this->manifest()); + } + + public function test_a_bare_subdomain_is_never_checked(): void + { + // It answers on that label across EVERY domain, so there is no single + // registered host to check it against. + $this->compile( + ['groups' => [['subdomain' => 'api', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]], + domains: ['example.com'], + ); + + self::assertArrayHasKey('GET@api /', $this->manifest()); + } + + public function test_a_project_that_registers_no_domains_is_not_checked(): void + { + $this->compile(['groups' => [['domain' => 'anything.at.all', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home'], + ]]]]); + + self::assertArrayHasKey('GET@anything.at.all /', $this->manifest()); + } + + // ── The two "global" guarantees ───────────────────────────────────────── + + public function test_an_ungrouped_route_is_reachable_from_every_domain(): void + { + $this->compile( + ['groups' => [['domain' => 'hkmvote.local', 'routes' => [ + ['method' => 'GET', 'path' => '/', 'handler' => 'A\\Vote@home'], + ]]]], + [['method' => 'GET', 'path' => '/health', 'handler' => 'A\\Shared@health']], + domains: ['hkmvote.local', 'africavoting.local'], + ); + + foreach (['hkmvote.local', 'africavoting.local', 'anything.example', 'localhost'] as $host) { + self::assertSame( + 'A\\Shared@health', + $this->matchHost('/health', $host)['entry']['handler'], + "ungrouped route must answer on {$host}", + ); + } + } + + public function test_a_subdomain_group_answers_on_that_label_of_every_domain(): void + { + // "api" without a domain ⇒ api.example.com AND api.example2.com AND any + // future host with that first label. + $this->compile(['groups' => [['subdomain' => 'api', 'prefix' => '/v1', 'routes' => [ + ['method' => 'GET', 'path' => '/ping', 'handler' => 'A\\Api@ping'], + ]]]]); + + foreach (['api.example.com', 'api.example2.com', 'api.brand-new.test'] as $host) { + self::assertSame( + 'A\\Api@ping', + $this->matchHost('/v1/ping', $host)['entry']['handler'], + "subdomain group must answer on {$host}", + ); + } + + self::assertNull($this->matchHost('/v1/ping', 'www.example.com')); + } + + // ── Names stay flat ───────────────────────────────────────────────────── + + public function test_route_names_remain_a_flat_namespace_across_domains(): void + { + // Two domains cannot both claim 'home'. UrlGenerator holds no request + // state — it could not pick between them — so this stays a boot failure + // and a group's name prefix is the intended fix. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/Duplicate route name \[home\]/'); + + $this->compile(['groups' => [ + ['domain' => 'hkmvote.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\V@h', 'name' => 'home']]], + ['domain' => 'africavoting.local', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\A@h', 'name' => 'home']]], + ]]); + } + + public function test_a_group_name_prefix_disambiguates_two_domains(): void + { + $this->compile(['groups' => [ + ['domain' => 'hkmvote.local', 'name' => 'vote.', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\V@h', 'name' => 'home']]], + ['domain' => 'africavoting.local', 'name' => 'africa.', 'routes' => [['method' => 'GET', 'path' => '/', 'handler' => 'A\\A@h', 'name' => 'home']]], + ]]); + + self::assertSame( + [ + 'vote.home' => ['path' => '/', 'method' => 'GET', 'domain' => 'hkmvote.local'], + 'africa.home' => ['path' => '/', 'method' => 'GET', 'domain' => 'africavoting.local'], + ], + $this->manifest('route-names.php'), + ); + } +} diff --git a/tests/Unit/Kernel/Pipelines/Http/RouteMatcherHardeningTest.php b/tests/Unit/Kernel/Pipelines/Http/RouteMatcherHardeningTest.php new file mode 100644 index 0000000..7717566 --- /dev/null +++ b/tests/Unit/Kernel/Pipelines/Http/RouteMatcherHardeningTest.php @@ -0,0 +1,285 @@ + $paths */ + private function matcher(array $paths, string $method = 'GET', bool $head = true): RouteMatcher + { + $manifest = []; + foreach ($paths as $path) { + $manifest[$method . ' ' . $path] = ['handler' => 'C@m', 'solves' => 'x']; + } + + return new RouteMatcher($manifest, headFallback: $head); + } + + // ── Percent-decoding: the segment guarantee must survive the decode ────── + + public function test_an_encoded_slash_cannot_smuggle_a_path_separator(): void + { + $m = $this->matcher(['/files/{name}']); + + // '%2F' is three ordinary characters, so it sails through [^/]+ — and the + // moment the controller decodes it, the "one segment" promise is gone. + self::assertNull($m->match('GET', '/files/..%2F..%2Fetc%2Fpasswd')); + } + + public function test_an_encoded_slash_is_rejected_for_a_typed_segment_too(): void + { + $m = $this->matcher(['/p/{slug:slug}']); + + self::assertNull($m->match('GET', '/p/a%2Fb')); + } + + public function test_a_captured_value_reaches_the_controller_decoded(): void + { + $m = $this->matcher(['/users/{name}']); + + // Previously the controller received the raw 'Jos%C3%A9'. + self::assertSame('José', $m->match('GET', '/users/Jos%C3%A9')['params']['name']); + } + + public function test_a_nul_byte_is_never_delivered(): void + { + $m = $this->matcher(['/files/{name:any}']); + + self::assertNull($m->match('GET', '/files/report%00.pdf')); + } + + public function test_an_undecodable_percent_is_passed_through_unchanged(): void + { + $m = $this->matcher(['/p/{code}']); + + // '%zz' is not a valid escape; rawurldecode leaves it alone and so do we. + self::assertSame('100%zz', $m->match('GET', '/p/100%zz')['params']['code']); + } + + // ── Anchoring ─────────────────────────────────────────────────────────── + + public function test_a_trailing_newline_does_not_satisfy_the_end_anchor(): void + { + $m = $this->matcher(['/users/{id:num}']); + + // Without the D modifier, PCRE's '$' also matches before a final newline. + self::assertNull($m->match('GET', "/users/12\n")); + self::assertNotNull($m->match('GET', '/users/12')); + } + + public function test_a_literal_dot_in_a_path_is_not_a_wildcard(): void + { + $m = $this->matcher(['/feed.xml/{id:num}']); + + self::assertNotNull($m->match('GET', '/feed.xml/1')); + self::assertNull($m->match('GET', '/feedXxml/1'), 'the dot must be quoted'); + } + + // ── New types ─────────────────────────────────────────────────────────── + + public function test_the_path_type_crosses_slashes_but_refuses_traversal(): void + { + $m = $this->matcher(['/dl/{file:path}']); + + self::assertSame('a/b/c.txt', $m->match('GET', '/dl/a/b/c.txt')['params']['file']); + self::assertNull($m->match('GET', '/dl/../../etc/passwd')); + self::assertNull($m->match('GET', '/dl/a/..%2Fb')); + } + + public function test_any_is_unchanged_and_still_a_bare_catch_all(): void + { + // `any` keeps its exact previous meaning so no existing route regresses; + // `path` is the safe alternative to opt into. + $m = $this->matcher(['/files/{p:any}']); + + self::assertSame('../secret', $m->match('GET', '/files/../secret')['params']['p']); + } + + /** @return array */ + public static function enumCases(): array + { + return [ + 'a member matches' => ['draft', true], + 'another member' => ['published', true], + 'a non-member does not' => ['deleted', false], + 'a prefix does not' => ['draf', false], + ]; + } + + #[DataProvider('enumCases')] + public function test_an_enum_type_admits_only_its_members(string $value, bool $expected): void + { + $m = $this->matcher(['/posts/{status:enum(draft|published)}']); + + self::assertSame($expected, $m->match('GET', '/posts/' . $value) !== null); + } + + public function test_enum_members_cannot_inject_regex(): void + { + // Members are preg_quote'd, so '.' is a literal dot, not "any character". + $m = $this->matcher(['/v/{v:enum(1.0|2.0)}']); + + self::assertNotNull($m->match('GET', '/v/1.0')); + self::assertNull($m->match('GET', '/v/1x0')); + } + + // ── Optional parameters ───────────────────────────────────────────────── + + public function test_an_optional_parameter_may_be_omitted_with_its_separator(): void + { + $m = $this->matcher(['/posts/{page?}']); + + self::assertNotNull($m->match('GET', '/posts')); + self::assertSame('', $m->match('GET', '/posts')['params']['page']); + self::assertSame('2', $m->match('GET', '/posts/2')['params']['page']); + } + + public function test_an_optional_parameter_still_honours_its_type(): void + { + $m = $this->matcher(['/posts/{page:num?}']); + + self::assertNotNull($m->match('GET', '/posts')); + self::assertNotNull($m->match('GET', '/posts/3')); + self::assertNull($m->match('GET', '/posts/three')); + } + + // ── Ordering across the bucket split ──────────────────────────────────── + + public function test_a_wildcard_route_declared_first_still_wins(): void + { + // '/{slug}' buckets as a wildcard and '/pages/{id}' under 'pages'; the + // matcher must merge them back into declaration order. + $m = $this->matcher(['/{slug}', '/pages/{id}']); + + self::assertSame(['slug' => 'pages'], $m->match('GET', '/pages')['params']); + } + + public function test_a_bucketed_route_declared_first_wins_over_a_wildcard(): void + { + $m = $this->matcher(['/pages/{id}', '/{a}/{b}']); + + self::assertSame(['id' => '7'], $m->match('GET', '/pages/7')['params']); + } + + public function test_a_route_in_another_bucket_is_never_reached(): void + { + $m = $this->matcher(['/users/{id}', '/posts/{id}']); + + self::assertNotNull($m->match('GET', '/posts/1')); + self::assertNull($m->match('GET', '/nope/1')); + } + + // ── HEAD and Allow ────────────────────────────────────────────────────── + + public function test_head_is_served_by_the_get_route(): void + { + $m = $this->matcher(['/health', '/users/{id:num}']); + + self::assertNotNull($m->match('HEAD', '/health')); + self::assertSame(['id' => '9'], $m->match('HEAD', '/users/9')['params']); + } + + public function test_head_fallback_can_be_switched_off(): void + { + $m = $this->matcher(['/health'], head: false); + + self::assertNull($m->match('HEAD', '/health')); + } + + public function test_allowed_methods_reports_the_other_verbs(): void + { + $manifest = [ + 'GET /things' => ['handler' => 'C@m', 'solves' => 'x'], + 'POST /things' => ['handler' => 'C@m', 'solves' => 'x'], + 'DELETE /t/{id}' => ['handler' => 'C@m', 'solves' => 'x'], + ]; + $m = new RouteMatcher($manifest); + + self::assertSame(['GET', 'POST', 'HEAD'], $m->allowedMethods('/things')); + self::assertSame(['DELETE'], $m->allowedMethods('/t/1')); + self::assertSame([], $m->allowedMethods('/nothing')); + } + + // ── Trailing-slash policy ─────────────────────────────────────────────── + + public function test_the_default_policy_is_strict(): void + { + $m = $this->matcher(['/users']); + + self::assertNull($m->match('GET', '/users/')); + self::assertNull($m->canonicalPath('GET', '/users/')); + } + + public function test_the_ignore_policy_matches_either_form(): void + { + $m = new RouteMatcher( + ['GET /users' => ['handler' => 'C@m', 'solves' => 'x']], + trailingSlash: RouteMatcher::TRAILING_IGNORE, + ); + + self::assertNotNull($m->match('GET', '/users/')); + } + + public function test_the_redirect_policy_reports_the_canonical_path(): void + { + $m = new RouteMatcher( + ['GET /users' => ['handler' => 'C@m', 'solves' => 'x']], + trailingSlash: RouteMatcher::TRAILING_REDIRECT, + ); + + self::assertNull($m->match('GET', '/users/'), 'redirect policy does not match directly'); + self::assertSame('/users', $m->canonicalPath('GET', '/users/')); + self::assertNull($m->canonicalPath('GET', '/'), 'the root has no alternate form'); + } + + // ── The precompiled index and the derived one must agree ──────────────── + + public function test_a_precompiled_index_matches_identically(): void + { + $manifest = [ + 'GET /users/{id:num}' => ['handler' => 'C@m', 'solves' => 'x'], + 'GET /users/me' => ['handler' => 'C@m', 'solves' => 'x'], + 'GET /{slug}' => ['handler' => 'C@m', 'solves' => 'x'], + ]; + + $derived = new RouteMatcher($manifest); + $compiled = RouteMatcher::fromCompiled(RouteIndex::build($manifest)); + + foreach (['/users/7', '/users/me', '/anything', '/users/a/b'] as $path) { + self::assertEquals( + $derived->match('GET', $path), + $compiled->match('GET', $path), + "diverged on {$path}", + ); + } + } + + public function test_a_route_pcre_cannot_represent_is_dropped_not_fatal(): void + { + // A manifest compiled by an older kernel may contain a duplicate capture + // name. That one route is unusable either way; the rest must still serve. + $m = $this->matcher(['/a/{id}/b/{id}', '/ok/{id}']); + + self::assertNull($m->match('GET', '/a/1/b/2')); + self::assertNotNull($m->match('GET', '/ok/1')); + } +} diff --git a/tests/Unit/Kernel/Pipelines/Http/RoutingStagesTest.php b/tests/Unit/Kernel/Pipelines/Http/RoutingStagesTest.php new file mode 100644 index 0000000..8652dd4 --- /dev/null +++ b/tests/Unit/Kernel/Pipelines/Http/RoutingStagesTest.php @@ -0,0 +1,272 @@ + $id, 'body' => 'x']); + } +} + +/** Records that it ran and what arguments the route handed it. */ +final class RecordingFilterStage implements HttpStageContract +{ + /** @var list>> */ + public static array $seen = []; + + public function handle(Request $request, callable $next): Response + { + self::$seen[] = $request->attribute('filter_args', []); + + return $next($request); + } +} + +/** + * The routing stages wired together — what the unit tests of RouteMatcher and + * the compiler cannot show on their own: that the precompiled entry keys the + * stages now read are actually the ones the pipeline produces, and that the old + * un-precompiled entry shape still works. + */ +#[CoversClass(ResolveStage::class)] +#[CoversClass(ExecuteStage::class)] +#[CoversClass(RouteFilterStage::class)] +#[CoversClass(FilterRegistry::class)] +final class RoutingStagesTest extends TestCase +{ + protected function setUp(): void + { + RecordingFilterStage::$seen = []; + } + + private function container(): ModuleContainer + { + return new ModuleContainer(new CoreContainer()); + } + + private function request(string $method, string $path): Request + { + return Request::create($path, $method)->withContainer($this->container()); + } + + /** @param array $entry */ + private function matcher(array $entry, string $key = 'GET /u/{id:num}'): RouteMatcher + { + return new RouteMatcher([$key => $entry]); + } + + /** @return array */ + private function precompiledEntry(): array + { + return [ + 'handler' => StageTestController::class . '@show', + 'class' => StageTestController::class, + 'action' => 'show', + 'solves' => '__project__', + 'filters' => [], + ]; + } + + // ── ResolveStage ──────────────────────────────────────────────────────── + + public function test_a_match_publishes_the_route_attributes(): void + { + $stage = new ResolveStage($this->matcher($this->precompiledEntry())); + + $seen = null; + $stage->handle($this->request('GET', '/u/7'), function (Request $r) use (&$seen): Response { + $seen = $r; + + return Response::empty(200); + }); + + self::assertSame(['id' => '7'], $seen->attribute('route_params')); + self::assertSame('__project__', $seen->attribute('target_service')); + self::assertSame(StageTestController::class, $seen->attribute('route_entry')['class']); + } + + public function test_a_miss_is_a_404_before_anything_downstream_runs(): void + { + $stage = new ResolveStage($this->matcher($this->precompiledEntry())); + + $response = $stage->handle( + $this->request('GET', '/nope'), + static fn(): Response => self::fail('the pipeline must stop at the miss'), + ); + + self::assertSame(404, $response->status()); + } + + public function test_a_wrong_method_is_a_404_by_default(): void + { + // 405 confirms that a path exists, so it stays opt-in. + $stage = new ResolveStage($this->matcher($this->precompiledEntry())); + + self::assertSame(404, $stage->handle( + $this->request('POST', '/u/7'), + static fn(): Response => Response::empty(200), + )->status()); + } + + public function test_405_is_returned_with_an_allow_header_when_enabled(): void + { + $stage = new ResolveStage($this->matcher($this->precompiledEntry()), methodNotAllowed: true); + + $response = $stage->handle( + $this->request('POST', '/u/7'), + static fn(): Response => Response::empty(200), + ); + + self::assertSame(405, $response->status()); + self::assertSame('GET, HEAD', $response->headers()['Allow']); + } + + public function test_a_face_restricted_route_is_invisible_on_another_face(): void + { + $entry = ['faces' => ['admin']] + $this->precompiledEntry(); + $stage = new ResolveStage($this->matcher($entry)); + + $request = $this->request('GET', '/u/7')->withAttribute('route_face', 'api'); + + self::assertSame(404, $stage->handle( + $request, + static fn(): Response => Response::empty(200), + )->status()); + } + + public function test_a_face_restricted_route_resolves_on_its_own_face(): void + { + $entry = ['faces' => ['admin']] + $this->precompiledEntry(); + $stage = new ResolveStage($this->matcher($entry)); + + $request = $this->request('GET', '/u/7')->withAttribute('route_face', 'admin'); + + self::assertSame(200, $stage->handle($request, static fn(): Response => Response::empty(200))->status()); + } + + public function test_an_unrestricted_route_is_unaffected_by_the_face(): void + { + $stage = new ResolveStage($this->matcher($this->precompiledEntry())); + $request = $this->request('GET', '/u/7')->withAttribute('route_face', 'api'); + + self::assertSame(200, $stage->handle($request, static fn(): Response => Response::empty(200))->status()); + } + + // ── ExecuteStage ──────────────────────────────────────────────────────── + + public function test_it_invokes_the_precompiled_class_and_action(): void + { + $request = $this->request('GET', '/u/7') + ->withAttribute('route_entry', $this->precompiledEntry()) + ->withAttribute('route_params', ['id' => '7']); + + $response = (new ExecuteStage())->handle($request, static fn(): Response => Response::empty(200)); + + self::assertSame(200, $response->status()); + self::assertStringContainsString('"id":"7"', $response->body()); + } + + public function test_a_legacy_entry_without_the_precompiled_split_still_runs(): void + { + // A manifest compiled by an older kernel has only `handler`. + $entry = [ + 'handler' => StageTestController::class . '@show', + 'solves' => '__project__', + ]; + + $request = $this->request('GET', '/u/7') + ->withAttribute('route_entry', $entry) + ->withAttribute('route_params', ['id' => '9']); + + $response = (new ExecuteStage())->handle($request, static fn(): Response => Response::empty(200)); + + self::assertStringContainsString('"id":"9"', $response->body()); + } + + public function test_a_head_request_keeps_the_headers_and_drops_the_body(): void + { + $request = $this->request('HEAD', '/u/7') + ->withAttribute('route_entry', $this->precompiledEntry()) + ->withAttribute('route_params', ['id' => '7']) + ->withAttribute('correlation_id', 'abc-123'); + + $response = (new ExecuteStage())->handle($request, static fn(): Response => Response::empty(200)); + + self::assertSame(200, $response->status()); + self::assertSame('', $response->body(), 'HEAD must not carry a body'); + self::assertSame('abc-123', $response->headers()['X-Correlation-ID']); + } + + // ── RouteFilterStage ──────────────────────────────────────────────────── + + public function test_precompiled_filter_specs_are_used_verbatim(): void + { + $registry = new FilterRegistry(); + $registry->register('throttle', RecordingFilterStage::class); + + $entry = $this->precompiledEntry(); + $entry['filter_specs'] = [['alias' => 'throttle', 'args' => ['60', '1']]]; + + $request = $this->request('GET', '/u/7')->withAttribute('route_entry', $entry); + $response = (new RouteFilterStage($registry, new CoreContainer())) + ->handle($request, static fn(): Response => Response::empty(204)); + + self::assertSame(204, $response->status()); + self::assertSame([['throttle' => ['60', '1']]], RecordingFilterStage::$seen); + } + + public function test_a_legacy_entry_falls_back_to_parsing_the_raw_specs(): void + { + $registry = new FilterRegistry(); + $registry->register('throttle', RecordingFilterStage::class); + + $entry = ['filters' => ['throttle:5,1']] + $this->precompiledEntry(); + + $request = $this->request('GET', '/u/7')->withAttribute('route_entry', $entry); + (new RouteFilterStage($registry, new CoreContainer())) + ->handle($request, static fn(): Response => Response::empty(204)); + + self::assertSame([['throttle' => ['5', '1']]], RecordingFilterStage::$seen); + } + + public function test_an_unknown_alias_stops_the_request_rather_than_skipping_the_filter(): void + { + $registry = new FilterRegistry(); + $request = $this->request('GET', '/u/7')->withAttribute( + 'route_entry', + ['filter_specs' => [['alias' => 'ghost', 'args' => []]]] + $this->precompiledEntry(), + ); + + $this->expectExceptionMessageMatches('/Unknown route filter alias \[ghost\]/'); + + (new RouteFilterStage($registry, new CoreContainer())) + ->handle($request, static fn(): Response => Response::empty(204)); + } + + public function test_a_resolved_filter_stage_is_reused_across_requests(): void + { + $registry = new FilterRegistry(); + $registry->register('throttle', RecordingFilterStage::class); + $core = new CoreContainer(); + + self::assertSame( + $registry->resolve('throttle', $core), + $registry->resolve('throttle', $core), + 'stages are stateless — constructing one per request is pure waste', + ); + } +} diff --git a/tests/Unit/Kernel/Routing/UrlGeneratorTest.php b/tests/Unit/Kernel/Routing/UrlGeneratorTest.php index dc1692f..ae09b0d 100644 --- a/tests/Unit/Kernel/Routing/UrlGeneratorTest.php +++ b/tests/Unit/Kernel/Routing/UrlGeneratorTest.php @@ -176,4 +176,143 @@ public function test_a_signature_from_a_different_key_is_rejected(): void self::assertFalse($this->generator(secret: 'a-completely-different-key')->hasValidSignature($signed)); } + + public function test_a_query_key_php_would_mangle_still_validates(): void + { + // parse_str() rewrites '.', ' ' and '[' inside parameter NAMES, so + // round-tripping the query through it made a legitimately signed URL + // impossible to verify. The comparison is now byte-for-byte. + $url = $this->generator()->signedRoute('search', ['user.name' => 'ada', 'a b' => 'c']); + + self::assertTrue($this->generator()->hasValidSignature($url)); + } + + public function test_a_second_injected_signature_is_rejected(): void + { + $url = $this->generator()->signedRoute('user.show', ['id' => 7]); + + self::assertFalse($this->generator()->hasValidSignature($url . '&signature=deadbeef')); + } + + public function test_tampering_with_the_query_invalidates_the_signature(): void + { + $url = $this->generator()->signedRoute('search', ['q' => 'safe']); + + self::assertFalse($this->generator()->hasValidSignature(str_replace('safe', 'evil', $url))); + } + + // ── Repeated and optional placeholders ────────────────────────────────── + + public function test_a_repeated_placeholder_is_substituted_everywhere(): void + { + $url = new UrlGenerator( + ['GET /a/{id}/b/{id}' => ['name' => 'twice', 'handler' => 'C@m']], + secret: self::SECRET, + ); + + // Consumption used to remove the value, so the second {id} reported a + // missing parameter. + self::assertSame('/a/7/b/7', $url->route('twice', ['id' => 7])); + } + + public function test_an_optional_parameter_may_be_omitted(): void + { + $url = new UrlGenerator( + ['GET /posts/{page:num?}' => ['name' => 'posts', 'handler' => 'C@m']], + secret: self::SECRET, + ); + + self::assertSame('/posts', $url->route('posts'), 'the separator goes with it'); + self::assertSame('/posts/2', $url->route('posts', ['page' => 2])); + } + + public function test_an_optional_parameter_is_still_type_checked(): void + { + $url = new UrlGenerator( + ['GET /posts/{page:num?}' => ['name' => 'posts', 'handler' => 'C@m']], + secret: self::SECRET, + ); + + $this->expectExceptionMessageMatches('/does not satisfy type \[num\]/'); + $url->route('posts', ['page' => 'two']); + } + + // ── Absolute URLs follow the route's own domain group ─────────────────── + + private function multiBrand(string $base = 'https://hkmvote.local'): UrlGenerator + { + return new UrlGenerator( + [ + 'GET@hkmvote.local /' => ['name' => 'vote.home', 'handler' => 'C@m'], + 'GET@africavoting.local /' => ['name' => 'africa.home', 'handler' => 'C@m'], + 'GET@*.africavoting.local /t' => ['name' => 'tenant.home', 'handler' => 'C@m'], + 'GET@api /ping' => ['name' => 'api.ping', 'handler' => 'C@m'], + 'GET /health' => ['name' => 'health', 'handler' => 'C@m'], + ], + base: $base, + secret: self::SECRET, + ); + } + + public function test_a_grouped_route_is_absolute_against_its_own_host(): void + { + // Generating both brands against one APP_URL would send half the links + // to the wrong site. + $url = $this->multiBrand(); + + self::assertSame('https://hkmvote.local/', $url->route('vote.home', absolute: true)); + self::assertSame('https://africavoting.local/', $url->route('africa.home', absolute: true)); + } + + public function test_the_scheme_is_taken_from_the_configured_base(): void + { + self::assertSame( + 'http://africavoting.local/', + $this->multiBrand(base: 'http://hkmvote.local')->route('africa.home', absolute: true), + ); + } + + public function test_a_wildcard_or_bare_subdomain_falls_back_to_the_base(): void + { + // Neither names a single host, so there is no origin to build. + $url = $this->multiBrand(); + + self::assertSame('https://hkmvote.local/t', $url->route('tenant.home', absolute: true)); + self::assertSame('https://hkmvote.local/ping', $url->route('api.ping', absolute: true)); + } + + public function test_an_ungrouped_route_still_uses_the_configured_base(): void + { + self::assertSame('https://hkmvote.local/health', $this->multiBrand()->route('health', absolute: true)); + } + + public function test_relative_generation_is_unaffected_by_the_domain(): void + { + self::assertSame('/', $this->multiBrand()->route('africa.home')); + } + + public function test_the_domain_of_a_named_route_is_reportable(): void + { + self::assertSame('africavoting.local', $this->multiBrand()->domainFor('africa.home')); + self::assertSame('', $this->multiBrand()->domainFor('health')); + } + + public function test_a_signed_absolute_url_uses_its_domain_and_still_verifies(): void + { + // The signature covers path+query only, never the host, so picking a + // per-domain origin cannot invalidate it. + $url = $this->multiBrand(); + $signed = $url->signedRoute('africa.home', ['id' => 7], absolute: true); + + self::assertStringStartsWith('https://africavoting.local/', $signed); + self::assertTrue($url->hasValidSignature(substr($signed, strlen('https://africavoting.local')))); + } + + public function test_a_trailing_newline_cannot_be_smuggled_into_a_value(): void + { + // '$' without the D modifier would accept "7\n" here and generate a URL + // the matcher then refuses. + $this->expectExceptionMessageMatches('/does not satisfy type \[num\]/'); + $this->generator()->route('user.show', ['id' => "7\n"]); + } } diff --git a/tests/Unit/Project/Bootstrap/ProjectRouteDeclarationTest.php b/tests/Unit/Project/Bootstrap/ProjectRouteDeclarationTest.php new file mode 100644 index 0000000..b64a7e8 --- /dev/null +++ b/tests/Unit/Project/Bootstrap/ProjectRouteDeclarationTest.php @@ -0,0 +1,130 @@ +project = sys_get_temp_dir() . '/hkm-projroutes-' . bin2hex(random_bytes(6)); + mkdir($this->project, 0775, true); + } + + protected function tearDown(): void + { + @unlink($this->project . '/proj.json'); + @rmdir($this->project); + } + + /** @param array $data */ + private function proj(array $data): void + { + file_put_contents($this->project . '/proj.json', json_encode($data)); + } + + public function test_route_groups_and_source_defaults_are_read(): void + { + $this->proj([ + 'name' => 'hkmvote', + 'routePrefix' => '/app', + 'groups' => [['domain' => 'hkmvote.local', 'routes' => []]], + 'ignored' => 'not a route declaration', + ]); + + $source = EntryHelpers::projectRouteGroups($this->project); + + self::assertSame('/app', $source['routePrefix']); + self::assertCount(1, $source['groups']); + self::assertArrayNotHasKey('ignored', $source); + } + + public function test_a_project_without_groups_yields_nothing(): void + { + $this->proj(['name' => 'plain']); + + self::assertSame([], EntryHelpers::projectRouteGroups($this->project)); + } + + public function test_a_missing_proj_json_yields_nothing(): void + { + self::assertSame([], EntryHelpers::projectRouteGroups($this->project . '/nope')); + self::assertSame([], EntryHelpers::projectRoutes($this->project . '/nope')); + } + + public function test_routes_pass_through_the_domain_name_and_faces_keys(): void + { + $this->proj([ + 'routes' => [[ + 'method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home', + 'name' => 'home', 'domain' => 'hkmvote.local', 'faces' => ['project'], + 'filters' => ['auth'], 'requires' => ['view.rendering'], + ]], + ]); + + $route = EntryHelpers::projectRoutes($this->project)[0]; + + self::assertSame('hkmvote.local', $route['domain']); + self::assertSame('home', $route['name']); + self::assertSame(['project'], $route['faces']); + self::assertSame(['auth'], $route['filters']); + self::assertSame(['view.rendering'], $route['requires']); + } + + public function test_a_subdomain_declaration_passes_through_too(): void + { + $this->proj([ + 'routes' => [[ + 'method' => 'GET', 'path' => '/', 'handler' => 'A\\C@home', + 'subdomain' => 'organizer', + ]], + ]); + + self::assertSame('organizer', EntryHelpers::projectRoutes($this->project)[0]['subdomain']); + } + + public function test_the_projects_registered_domains_are_read(): void + { + $this->proj(['name' => 'hkmvote', 'domains' => ['HKMVote.local', ' africavoting.local ', '']]); + + self::assertSame( + ['hkmvote.local', 'africavoting.local'], + EntryHelpers::projectDomains($this->project), + ); + } + + public function test_a_project_without_domains_reads_as_unregistered(): void + { + // Which disables the route-domain check entirely — a project that does + // not register its hosts has no registry to validate against. + $this->proj(['name' => 'plain']); + + self::assertSame([], EntryHelpers::projectDomains($this->project)); + self::assertSame([], EntryHelpers::projectDomains($this->project . '/nope')); + } + + public function test_a_malformed_route_is_dropped_rather_than_fatal(): void + { + $this->proj(['routes' => [['method' => 'GET'], ['method' => 'GET', 'path' => '/ok', 'handler' => 'A\\C@ok']]]); + + $routes = EntryHelpers::projectRoutes($this->project); + + self::assertCount(1, $routes); + self::assertSame('/ok', $routes[0]['path']); + } +} diff --git a/tools/build.zig b/tools/build.zig index 7d1a4b5..b87debe 100644 --- a/tools/build.zig +++ b/tools/build.zig @@ -69,7 +69,7 @@ pub fn build(b: *std.Build) void { const unit_tests = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), + .root_source_file = b.path("src/tests.zig"), .target = target, .optimize = optimize, }), diff --git a/tools/src/commands/module.zig b/tools/src/commands/module.zig index ec84875..b6760a5 100644 --- a/tools/src/commands/module.zig +++ b/tools/src/commands/module.zig @@ -335,7 +335,17 @@ fn removeModule(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []cons _ = try runGit(allocator, io, env, root, &.{ "submodule", "deinit", "-f", rel }); _ = try runGit(allocator, io, env, root, &.{ "rm", "-f", rel }); Dir.cwd().deleteTree(io, try std.fmt.allocPrint(allocator, "{s}/.git/modules/{s}", .{ root, rel })) catch {}; - Dir.cwd().deleteTree(io, modulePath) catch {}; + // Kept, not swallowed: reporting "Removed submodule X" for a directory that + // is still on disk sends the reader looking for a different problem. + var delete_failed = false; + Dir.cwd().deleteTree(io, modulePath) catch { + delete_failed = true; + }; + if (delete_failed) { + prompt.err(try std.fmt.allocPrint(allocator, "could not delete {s} — it is still on disk.", .{modulePath})); + prompt.muted(" remove it by hand, then re-run to finish unwiring composer.json."); + return 1; + } // `git rm` already strips the .gitmodules section on modern git, so this is a // best-effort fallback for older git — silence its "no such section" noise. _ = try runGitQuiet(allocator, io, env, root, &.{ "config", "-f", ".gitmodules", "--remove-section", try std.fmt.allocPrint(allocator, "submodule.{s}", .{rel}) }); diff --git a/tools/src/commands/new.zig b/tools/src/commands/new.zig index 3f6440e..c6b3e8f 100644 --- a/tools/src/commands/new.zig +++ b/tools/src/commands/new.zig @@ -30,6 +30,7 @@ const prompt = @import("../lib/prompt.zig"); const util = @import("../lib/util.zig"); const services = @import("../lib/services.zig"); const plugin_assets = @import("../lib/plugin_assets.zig"); +const plugins_cmd = @import("plugins.zig"); const plugin_boot = @import("../lib/plugin_bootstrap.zig"); const installer = @import("../lib/plugin_install.zig"); @@ -117,6 +118,12 @@ const Options = struct { /// Domains for proj.json + the kernel registry. Null until resolved (flag or /// interactive prompt); see resolveDomains(). domains: ?[]const []const u8 = null, + /// --verify-plugins: run each plugin's own test suite while installing. + /// Off by default — scaffolding installs ~19 pinned, already-released + /// plugins, and testing each costs a composer install plus a phpunit run. + verify_plugins: bool = false, + /// Template variant: "" for the full starter, "simple" for the empty one. + variant: []const u8 = "", /// --no-register skips writing to the kernel projects.json registry. register: bool = true, /// --no-install skips running `composer install` after scaffolding. @@ -135,6 +142,8 @@ fn parse(allocator: std.mem.Allocator, args: []const []const u8) !?Options { var register = true; var install = true; var key = true; + var verify_plugins = false; + var variant: []const u8 = ""; var i: usize = 2; while (i < args.len) : (i += 1) { @@ -151,6 +160,14 @@ fn parse(allocator: std.mem.Allocator, args: []const []const u8) !?Options { if (i + 1 >= args.len) return error.MissingDomainsValue; i += 1; domains_csv = args[i]; + } else if (std.mem.eql(u8, a, "--simple") or std.mem.eql(u8, a, "--empty") or + std.mem.eql(u8, a, "--minimal")) + { + variant = "simple"; + } else if (std.mem.startsWith(u8, a, "--template=")) { + variant = a["--template=".len..]; + } else if (std.mem.eql(u8, a, "--verify-plugins")) { + verify_plugins = true; } else if (std.mem.eql(u8, a, "--no-register")) { register = false; } else if (std.mem.eql(u8, a, "--no-install")) { @@ -173,6 +190,8 @@ fn parse(allocator: std.mem.Allocator, args: []const []const u8) !?Options { .name = try allocator.dupe(u8, resolved_name), .studly = try studly(allocator, resolved_name), .domains = if (domains_csv) |csv| try splitDomains(allocator, csv) else null, + .verify_plugins = verify_plugins, + .variant = variant, .register = register, .install = install, .key = key, @@ -215,6 +234,9 @@ fn printHelp() void { prompt.item(" --project=", "project name (default: derived from path)"); prompt.item(" --domains=a.com,b.com", "comma-separated domains to register"); prompt.item(" --no-register", "skip kernel registry registration"); + prompt.item(" --simple", "empty project: no plugins at all (aliases: --empty/--minimal)"); + prompt.item(" --template=", "scaffold from a template variant under templates//"); + prompt.item(" --verify-plugins", "run each plugin's test suite while installing (slow)"); prompt.item(" --help, -h", "show this help"); prompt.blank(); prompt.section("Example"); @@ -265,8 +287,10 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c return 1; }; prompt.muted(try std.fmt.allocPrint(allocator, "templates: {s}", .{tpl_dir})); + var written: usize = 0; for (templates) |t| { - const raw = (try templateBody(allocator, io, tpl_dir, t)) orelse { + if (skippedByVariant(opts.variant, t.dest)) continue; + const raw = (try templateBody(allocator, io, tpl_dir, t, opts.variant)) orelse { prompt.err(try std.fmt.allocPrint( allocator, "Missing template '{s}' in {s}", @@ -277,8 +301,9 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c const data = try render(allocator, raw, opts); const p = try util.join(allocator, opts.path, t.dest); try cwd.writeFile(io, .{ .sub_path = p, .data = data }); + written += 1; } - prompt.ok(try std.fmt.allocPrint(allocator, "Scaffolded {d} files", .{templates.len})); + prompt.ok(try std.fmt.allocPrint(allocator, "Scaffolded {d} files", .{written})); // 3. register the project in the kernel's projects.json registry. if (opts.register) { @@ -303,12 +328,24 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c // repositories, so a freshly scaffolded project died on its first request // with `Class "Plugins\Logger\Provider" does not exist`. They are fetched // from git here, which is the same path `hkm plugins install` uses. + var plugins_missing: usize = 0; if (opts.install) { - installBootstrapPlugins(allocator, io, env, opts) catch { + plugins_missing = installBootstrapPlugins(allocator, io, env, opts) catch blk: { prompt.warn("Could not install the bootstrap's plugins — run 'hkm plugins install ' later."); + break :blk 1; }; } + // 6b. wire each installed plugin's Support/helpers.php require. + // + // A helpers file defines global functions (`view()`, `cookie()`, …) that the + // plugin's own code calls. Nothing autoloads a bare function file, so an + // unwired one is an undefined-function fatal at the first call — a project + // that scaffolds cleanly and dies on its first request. + if (opts.install) { + _ = plugins_cmd.healSupportRequires(allocator, io, env, opts.path, false) catch 0; + } + // 7. publish the assets (config/migrations/seeders/factories/resources) of // every plugin the project bootstrap enables (copy only — no migrate). plugin_assets.publishEnabled(allocator, io, env, opts.path) catch { @@ -320,7 +357,25 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c prompt.note("Next steps:"); prompt.muted(try std.fmt.allocPrint(allocator, " cd {s}", .{opts.path})); if (!opts.install) prompt.muted(" composer install"); - prompt.muted(" hkm run # or: php -S localhost:8000 -t app/public"); + // Only offer `hkm run` when running it would actually work — otherwise the + // next step is installing what is missing, listed above. + if (plugins_missing > 0) { + prompt.muted(" # install the missing plugins listed above first"); + } else { + prompt.muted(" hkm run # or: php -S localhost:8000 -t app/public"); + } + + // Saying "ready" when the providers the bootstrap wires are not on disk + // sends the user to `hkm run` for a fatal they were already warned about + // twenty lines earlier — and the last line is the one that gets read. + if (plugins_missing > 0) { + prompt.outro(try std.fmt.allocPrint( + allocator, + "Project '{s}' scaffolded — but {d} plugin(s) are missing, so it will not boot yet", + .{ opts.name, plugins_missing }, + )); + return 1; + } prompt.outro(try std.fmt.allocPrint(allocator, "Project '{s}' is ready", .{opts.name})); return 0; @@ -452,12 +507,47 @@ fn registerProject(allocator: std.mem.Allocator, io: Io, env: *EnvMap, opts: Opt /// The body for one template: read `/` from disk. Templates with no /// `src` (.gitkeep) are always empty. Returns null when a required source file /// is missing on disk (the caller turns this into a clear error). -fn templateBody(allocator: std.mem.Allocator, io: Io, dir: []const u8, t: Template) !?[]const u8 { +/// Read a template file, letting a VARIANT override individual files. +/// +/// A variant ships only what differs — `templates/simple/` is one file, the +/// bootstrap — and everything else resolves to the shared template. A full +/// parallel tree would double every file in it and start drifting on the first +/// edit that only landed in one copy. +fn templateBody(allocator: std.mem.Allocator, io: Io, dir: []const u8, t: Template, variant: []const u8) !?[]const u8 { const src = t.src orelse return ""; + + if (variant.len > 0) { + const override = try std.fmt.allocPrint(allocator, "{s}/{s}/{s}", .{ dir, variant, src }); + if (Dir.cwd().readFileAlloc(io, override, allocator, .limited(8 * 1024 * 1024))) |body| { + return body; + } else |_| {} + } + const path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir, src }); return Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch null; } +/// Files that only make sense alongside the plugin they configure. +/// +/// `config/storage.php` configures the Storage plugin, `config/let-migrate.php` +/// the Database one. Scaffolding them into a project with no plugins leaves a +/// beginner reading configuration for something that is not installed and +/// wondering what they are missing. `hkm plugins install storage` publishes its +/// own config when the plugin actually arrives. +const plugin_owned_config = [_][]const u8{ + "config/storage.php", + "config/let-migrate.php", + "resources/welcome.php", +}; + +fn skippedByVariant(variant: []const u8, dest: []const u8) bool { + if (!std.mem.eql(u8, variant, "simple")) return false; + for (plugin_owned_config) |p| { + if (std.mem.eql(u8, p, dest)) return true; + } + return false; +} + // -------------------------------------------------------------------------- // template rendering // -------------------------------------------------------------------------- @@ -497,9 +587,10 @@ fn domainsJson(allocator: std.mem.Allocator, domains: []const []const u8) ![]con /// never drift from what the template actually wires — a list here that fell /// behind the template would reproduce exactly the missing-class failure this /// exists to prevent. -fn installBootstrapPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, opts: Options) !void { +/// Returns the number of plugins that could NOT be installed. +fn installBootstrapPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, opts: Options) !usize { const bootstrap = try util.join(allocator, opts.path, "app/bootstrap/app.php"); - const source = Dir.cwd().readFileAlloc(io, bootstrap, allocator, .limited(4 * 1024 * 1024)) catch return; + const source = Dir.cwd().readFileAlloc(io, bootstrap, allocator, .limited(4 * 1024 * 1024)) catch return 0; var aliases: std.ArrayList(plugin_boot.Alias) = .empty; try plugin_boot.collectAliases(allocator, source, &aliases); @@ -507,27 +598,38 @@ fn installBootstrapPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, o var enabled: std.ArrayList(plugin_boot.Enabled) = .empty; try plugin_boot.collectEnabled(allocator, source, aliases.items, &enabled); - if (enabled.items.len == 0) return; + if (enabled.items.len == 0) return 0; prompt.section("Installing plugins"); var ok: usize = 0; - var failed: usize = 0; + // Names, not just a count: the message that follows is the only place the + // user learns WHICH plugins are missing, and "3 of 19 failed" leaves them + // diffing the bootstrap against plugins/ to find out. + var failed: std.ArrayList([]const u8) = .empty; for (enabled.items) |e| { - const outcome = installer.install(allocator, io, env, opts.path, e.name, .{}) catch { - failed += 1; + const outcome = installer.install(allocator, io, env, opts.path, e.name, .{ + .interactive = false, + // Scaffolding installs ~19 pinned, already-released plugins. Running + // each one's suite means a composer install plus a phpunit run per + // plugin — tens of minutes, for versions that were tested when they + // were released. Verification stays the default for a deliberate + // single install, where it is worth the wait; here it is opt-in. + .verify = opts.verify_plugins, + }) catch { + try failed.append(allocator, e.name); continue; }; switch (outcome) { .refused => |why| { - failed += 1; + try failed.append(allocator, e.name); prompt.warn(why); }, - .installed, .up_to_date, .updated => { + .installed, .up_to_date, .linked, .updated => { ok += 1; _ = installer.report(allocator, e.name, outcome, false) catch {}; switch (outcome) { - .installed, .up_to_date => |entry| installer.recordInLock(allocator, io, opts.path, entry) catch {}, + .installed, .up_to_date, .linked => |entry| installer.recordInLock(allocator, io, opts.path, entry) catch {}, .updated => |u| installer.recordInLock(allocator, io, opts.path, u.to) catch {}, .refused => {}, } @@ -535,13 +637,23 @@ fn installBootstrapPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, o } } - if (failed > 0) { + if (failed.items.len > 0) { prompt.warn(try std.fmt.allocPrint( allocator, - "{d} of {d} plugin(s) could not be installed — the project will not boot until they are. Retry with 'hkm plugins install '.", - .{ failed, enabled.items.len }, + "{d} of {d} plugin(s) could not be installed — the project will not boot until they are:", + .{ failed.items.len, enabled.items.len }, )); + // One command per plugin, and no separate list of bare names above it: + // the commands already name every one, and printing both meant reading + // the same nineteen names twice. `install` takes a SINGLE plugin — its + // second positional is the project path, so space-joining the names + // would install the first and treat the rest as a directory. + for (failed.items) |name| { + prompt.muted(try std.fmt.allocPrint(allocator, " hkm plugins install {s}", .{name})); + } } else { prompt.ok(try std.fmt.allocPrint(allocator, "{d} plugin(s) installed", .{ok})); } + + return failed.items.len; } diff --git a/tools/src/commands/plugins.zig b/tools/src/commands/plugins.zig index d50be79..eabe5e3 100644 --- a/tools/src/commands/plugins.zig +++ b/tools/src/commands/plugins.zig @@ -22,6 +22,12 @@ const pgit = @import("../lib/plugin_git.zig"); const plock = @import("../lib/plugin_lock.zig"); const pregistry = @import("../lib/plugin_registry.zig"); const banner = @import("../lib/banner.zig"); +const plugin_ui = @import("../lib/plugin_ui.zig"); +const registry = @import("../lib/registry.zig"); +const domains = @import("../lib/plugin_domains.zig"); +const pstore = @import("../lib/plugin_store.zig"); +const userconfig = @import("../lib/userconfig.zig"); +const plugin_assets = @import("../lib/plugin_assets.zig"); const services = @import("../lib/services.zig"); const Dir = std.Io.Dir; @@ -33,7 +39,7 @@ const Located = sources.Located; const Enabled = boot.Enabled; const Activation = boot.Activation; -const Action = enum { analyze, verify, recover, enable, disable, update, upgrade, create, delete, make_migration, make_seeder, make_factory, install, uninstall, versions, outdated, sync_lock }; +const Action = enum { analyze, verify, recover, enable, disable, update, upgrade, create, delete, make_migration, make_seeder, make_factory, install, uninstall, versions, outdated, sync_lock, prune, domain_map, store_cmd }; pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []const u8) !u8 { var action: Action = .analyze; @@ -48,6 +54,25 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c var force = false; // --full: clone full history instead of --depth 1. var full_clone = false; + // --no-verify: install without running the plugin's test suite. The run + // costs a composer install per plugin, so it has to be skippable. + var verify = true; + // Was --verify / --no-verify given explicitly? A single deliberate install + // verifies by default; a BATCH (restore, or a dependency closure) does not, + // because the cost is a composer install plus a phpunit run PER PLUGIN and + // the versions being restored were tested when they were released. An + // explicit flag overrides either default. + var verify_explicit = false; + // --no-deps: install ONLY what was named. Dependencies come from the + // plugin's requires[] and are fetched by default, because a plugin without + // them is on disk and still cannot boot. + var with_deps = true; + // --set= / --migrate for `hkm plugins store`. + var set_store: []const u8 = ""; + // --latest: ignore the lock's pinned versions and take the newest release + // of every plugin. The opposite of the default, which is reproducibility. + var want_latest = false; + var migrate_store = false; var operands: std.ArrayList([]const u8) = .empty; var saw_action = false; @@ -71,6 +96,20 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c force = true; } else if (std.mem.eql(u8, a, "--full")) { full_clone = true; + } else if (std.mem.eql(u8, a, "--no-verify") or std.mem.eql(u8, a, "--skip-tests")) { + verify = false; + verify_explicit = true; + } else if (std.mem.eql(u8, a, "--verify") or std.mem.eql(u8, a, "--run-tests")) { + verify = true; + verify_explicit = true; + } else if (std.mem.eql(u8, a, "--no-deps")) { + with_deps = false; + } else if (std.mem.startsWith(u8, a, "--set=")) { + set_store = a["--set=".len..]; + } else if (std.mem.eql(u8, a, "--migrate")) { + migrate_store = true; + } else if (std.mem.eql(u8, a, "--latest") or std.mem.eql(u8, a, "--upgrade")) { + want_latest = true; } else if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { printHelp(); return 0; @@ -89,16 +128,37 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c switch (action) { .analyze => return analyze(allocator, io, env, op(ops, 0), show_all), .install => { + // No plugin named → install everything the PROJECT declares, the + // way `composer install` does. Disambiguated the same way `update` + // is: an operand that resolves to a project root is the target, not + // a plugin — otherwise `hkm plugins install ./my-app` would try to + // install the project directory as a git remote. + const batch_verify = verify_explicit and verify; if (ops.len == 0) { - prompt.err("Usage: hkm plugins install [path|name] [--version=vX.Y.Z] [--force] [--full] [--dry-run]"); - return 2; + return restoreCmd(allocator, io, env, ".", .{ + .dry_run = dry_run, + .force = force, + .full = full_clone, + .verify = batch_verify, + }, with_deps, want_latest); + } + if (ops.len == 1) { + if ((try services.resolveRoot(allocator, io, env, op(ops, 0))) != null) { + return restoreCmd(allocator, io, env, op(ops, 0), .{ + .dry_run = dry_run, + .force = force, + .full = full_clone, + .verify = batch_verify, + }, with_deps, want_latest); + } } return installCmd(allocator, io, env, op(ops, 0), op(ops, 1), .{ .version = want_version, .dry_run = dry_run, .force = force, .full = full_clone, - }); + .verify = verify, + }, with_deps); }, .uninstall => { if (ops.len == 0) { @@ -115,12 +175,15 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c return versionsCmd(allocator, io, env, op(ops, 0)); }, .outdated => return outdatedCmd(allocator, io, env, op(ops, 0)), + .prune => return pruneCmd(allocator, io, env, op(ops, 0), dry_run), + .domain_map => return domainsCmd(allocator, io, env, op(ops, 0)), + .store_cmd => return storeCmd(allocator, io, env, set_store, migrate_store, dry_run), .sync_lock => return lockCmd(allocator, io, env, op(ops, 0), dry_run, .{ .version = want_version, .force = force, .full = full_clone, }), - .verify => return verifyPlugins(allocator, io, env, op(ops, 0), fix), + .verify => return verifyPlugins(allocator, io, env, op(ops, 0), fix, dry_run), .recover => return recoverAssets(allocator, io, env, op(ops, 0), dry_run), .enable, .disable => { if (ops.len == 0) { @@ -188,7 +251,8 @@ fn actionFromWordOpt(a: []const u8) ?Action { if (std.mem.eql(u8, a, "disable") or std.mem.eql(u8, a, "remove") or std.mem.eql(u8, a, "off")) return .disable; if (std.mem.eql(u8, a, "update") or std.mem.eql(u8, a, "sync")) return .update; if (std.mem.eql(u8, a, "upgrade") or std.mem.eql(u8, a, "reconcile") or std.mem.eql(u8, a, "migrate")) return .upgrade; - if (std.mem.eql(u8, a, "create") or std.mem.eql(u8, a, "new") or std.mem.eql(u8, a, "scaffold")) return .create; + if (std.mem.eql(u8, a, "create") or std.mem.eql(u8, a, "new") or + std.mem.eql(u8, a, "make") or std.mem.eql(u8, a, "scaffold")) return .create; if (std.mem.eql(u8, a, "delete") or std.mem.eql(u8, a, "del") or std.mem.eql(u8, a, "destroy") or std.mem.eql(u8, a, "rm")) return .delete; if (std.mem.eql(u8, a, "make:migration") or std.mem.eql(u8, a, "make-migration") or @@ -204,7 +268,12 @@ fn actionFromWordOpt(a: []const u8) ?Action { if (std.mem.eql(u8, a, "versions") or std.mem.eql(u8, a, "releases")) return .versions; if (std.mem.eql(u8, a, "outdated")) return .outdated; if (std.mem.eql(u8, a, "lock")) return .sync_lock; - if (std.mem.eql(u8, a, "list") or std.mem.eql(u8, a, "ls")) return .analyze; + if (std.mem.eql(u8, a, "prune") or std.mem.eql(u8, a, "gc")) return .prune; + if (std.mem.eql(u8, a, "domains")) return .domain_map; + if (std.mem.eql(u8, a, "store") or std.mem.eql(u8, a, "cache")) return .store_cmd; + if (std.mem.eql(u8, a, "list") or std.mem.eql(u8, a, "ls") or + std.mem.eql(u8, a, "analyze") or std.mem.eql(u8, a, "analyse") or + std.mem.eql(u8, a, "status")) return .analyze; if (std.mem.eql(u8, a, "verify") or std.mem.eql(u8, a, "check") or std.mem.eql(u8, a, "doctor") or std.mem.eql(u8, a, "scan") or std.mem.eql(u8, a, "audit")) return .verify; if (std.mem.eql(u8, a, "recover") or std.mem.eql(u8, a, "recover-assets") or @@ -215,15 +284,41 @@ fn actionFromWordOpt(a: []const u8) ?Action { /// Resolve a project root from `target` or error out. Shared by every action. fn requireRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8) !?[]const u8 { return (try services.resolveRoot(allocator, io, env, target)) orelse { - prompt.err(try std.fmt.allocPrint( - allocator, - "'{s}' is neither a project folder (with proj.json) nor a registered name.", - .{if (target.len == 0) "." else target}, - )); + // Distinguish "you named something wrong" from "you are standing in the + // wrong directory". The second is what happens when a command that + // defaults to the cwd is run from the kernel checkout, and telling + // someone that '.' is not a registered name explains nothing. + const implicit = target.len == 0 or std.mem.eql(u8, target, "."); + if (implicit) { + prompt.err("This directory is not a project — there is no proj.json here."); + } else { + prompt.err(try std.fmt.allocPrint( + allocator, + "'{s}' is neither a project folder (with proj.json) nor a registered name.", + .{target}, + )); + } + + prompt.muted(" run it from inside a project, or name one: hkm plugins "); + listKnownProjects(allocator, io, env); return null; }; } +/// Name the projects the kernel already knows, so "name one" is actionable +/// rather than an instruction to go and remember what they are called. +fn listKnownProjects(allocator: std.mem.Allocator, io: Io, env: *EnvMap) void { + const jsonPath = (registry.resolvePath(allocator, io, env) catch return) orelse return; + const entries = registry.list(allocator, io, jsonPath) catch return; + if (entries.len == 0) return; + + prompt.muted(""); + prompt.muted(" registered projects:"); + for (entries) |e| { + prompt.muted(std.fmt.allocPrint(allocator, " {s: <16}{s}", .{ e.name, e.path }) catch continue); + } +} + fn readBootstrap(allocator: std.mem.Allocator, io: Io, bootstrap: []const u8) !?[]const u8 { return Dir.cwd().readFileAlloc(io, bootstrap, allocator, .limited(4 * 1024 * 1024)) catch { prompt.err(try std.fmt.allocPrint(allocator, "Cannot read {s}", .{bootstrap})); @@ -358,7 +453,7 @@ fn subtreeLabel(sub: []const u8) []const u8 { /// seeders, factories, views) copied into the project + tracked in the manifest? /// Also checks the Support/helpers.php require. Reports per plugin; `--fix` /// delegates to `update` to publish anything missing and heal support requires. -fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8, fix: bool) !u8 { +fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8, fix: bool, dry_run: bool) !u8 { const root = (try requireRoot(allocator, io, env, target)) orelse return 1; const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); @@ -411,6 +506,10 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c var plugins_dir: ?[]const u8 = null; var plugin_path: ?[]const u8 = null; var src_label: []const u8 = "—"; + // A link into the shared store whose target is gone. dirExists follows + // symlinks, so this is indistinguishable from "never installed" unless + // it is checked for separately — and the two need different repairs. + var dangling: ?[]const u8 = null; for (search) |src| { const d = srcs.dirFor(src) orelse continue; const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ d, e.name }); @@ -420,6 +519,7 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c src_label = sources.sourceLabel(src); break; } + if (dangling == null and util.isSymlink(io, fp)) dangling = fp; } if (e.solves) |s| { @@ -428,18 +528,28 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c } if (plugin_path) |pp| { prompt.muted(try std.fmt.allocPrint(allocator, " source: {s} ({s})", .{ src_label, pp })); + } else if (dangling) |dp| { + prompt.err(try std.fmt.allocPrint( + allocator, + " \u{2717} broken link — {s} points into the shared store, but the target is gone", + .{dp}, + )); + prompt.muted(" repair: hkm plugins lock (restores every plugin at its locked version)"); + issues += 1; } else { - prompt.err(" ✗ plugin folder not found on disk — cannot verify its assets"); + prompt.err(" \u{2717} plugin folder not found on disk — cannot verify its assets"); + prompt.muted(try std.fmt.allocPrint(allocator, " install: hkm plugins install {s}", .{e.name})); issues += 1; } // 1. requires[] — each must be solved by another ENABLED plugin, be a // kernel port (no plugin provides it), else it is a real gap. const meta = if (plugins_dir) |pd| try sources.readModuleMeta(allocator, io, pd, e.name) else null; - const requires = if (meta) |m| m.requires else &[_][]const u8{}; + const requires = if (meta) |m| m.requires else &[_]sources.Requirement{}; if (requires.len > 0) { prompt.muted(" requires:"); - for (requires) |req| { + for (requires) |r| { + const req = r.domain; if (enabledSolves(enabled.items, req)) |provider| { prompt.muted(try std.fmt.allocPrint(allocator, " ✓ {s} ({s})", .{ req, provider })); } else if (deps.providerForDomain(cat.items, req)) |p| { @@ -492,8 +602,10 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c // 3. Support/helpers.php require wiring. const helpers = try std.fmt.allocPrint(allocator, "{s}/Support/helpers.php", .{pp}); if (util.fileExists(io, helpers)) { - const stag = try boot.supportTag(allocator, e.name); - if (std.mem.indexOf(u8, source, stag) != null) { + // Checked against the require itself, not just its marker + // comment — see supportRequireWired. + const expr = (try supportHelpersExpr(allocator, io, env, root, pp)) orelse ""; + if (boot.supportRequireWired(allocator, source, e.name, expr)) { prompt.muted(" ✓ Support/helpers.php require wired"); } else { prompt.err(" ✗ ships Support/helpers.php but its require is NOT wired in the bootstrap"); @@ -519,8 +631,15 @@ fn verifyPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []c prompt.warn(try std.fmt.allocPrint(allocator, "{d} plugin(s) with {d} issue(s) total", .{ with_issues, total_issues })); if (fix) { - prompt.section("Fixing — publishing missing assets + wiring Support requires"); - _ = try updatePlugins(allocator, io, env, "", target, false); + // --dry-run is threaded through, not dropped. It used to pass a + // hardcoded false, so `verify --fix --dry-run` — a command whose whole + // purpose is to show what WOULD change — published assets, ran + // migrations and rewrote the bootstrap. + prompt.section(if (dry_run) + "Fixing (dry run) — what would be published and wired" + else + "Fixing — publishing missing assets + wiring Support requires"); + _ = try updatePlugins(allocator, io, env, "", target, dry_run); prompt.note("Re-run `hkm plugins verify` to confirm; unmet requires need `hkm plugins enable `."); return 0; } @@ -659,13 +778,19 @@ fn enableWithDeps( if (located == null and !dry_run) { prompt.muted(try std.fmt.allocPrint(allocator, "{s} is not installed — fetching it…", .{folder})); - const outcome = try installer.install(allocator, io, env, root, folder, .{}); + // Honour a remote the lock already records: a plugin installed from a + // URL must be re-fetched from that URL, not from the registry's guess + // at the same name. + const known = if (plock.read(allocator, io, root)) |l| l.find(folder) else |_| null; + const outcome = try installer.install(allocator, io, env, root, folder, .{ + .remote = if (known) |k| k.remote else "", + }); switch (outcome) { .refused => |why| { prompt.err(why); return 1; }, - .installed, .up_to_date, .updated => { + .installed, .up_to_date, .linked, .updated => { fetched = outcome; _ = try installer.report(allocator, folder, outcome, false); }, @@ -687,20 +812,36 @@ fn enableWithDeps( var steps: std.ArrayList(Step) = .empty; for (needed.items) |dep| { if (boot.findEnabled(enabled, dep.located.name) != null) continue; // already wired - // Deps are pulled into the route graph via requires[] → on-demand is correct. - try steps.append(allocator, .{ .folder = dep.located.name, .dir = dep.located.dir, .essential = false, .dependency = true }); + // Deps reach the route graph through requires[], so on-demand is right + // for them — UNLESS the plugin itself says it cannot work that way. + try steps.append(allocator, .{ + .folder = dep.located.name, + .dir = dep.located.dir, + .essential = declaresEssential(allocator, io, dep.located.dir, dep.located.name), + .dependency = true, + }); } const target_enabled = boot.findEnabled(enabled, folder) != null; if (!target_enabled) { const dir = if (located) |l| l.dir else if (deps.findByName(cat, folder)) |p| p.located.dir else null; - try steps.append(allocator, .{ .folder = folder, .dir = dir, .essential = essential, .dependency = false }); + // -e forces it; otherwise the plugin's own manifest decides. + const as_essential = essential or + (if (dir) |d| declaresEssential(allocator, io, d, folder) else false); + if (as_essential and !essential) { + prompt.muted(try std.fmt.allocPrint( + allocator, + "{s} declares activation: essential — wiring it into withEssentialModules()", + .{folder}, + )); + } + try steps.append(allocator, .{ .folder = folder, .dir = dir, .essential = as_essential, .dependency = false }); } if (fetched) |outcome| { // Record it only after the fetch succeeded, so the lock never names a // plugin the project does not actually have. switch (outcome) { - .installed, .up_to_date => |e| try installer.recordInLock(allocator, io, root, e), + .installed, .up_to_date, .linked => |e| try installer.recordInLock(allocator, io, root, e), .updated => |u| try installer.recordInLock(allocator, io, root, u.to), .refused => {}, } @@ -765,7 +906,7 @@ fn phpQuote(allocator: std.mem.Allocator, s: []const u8) ![]const u8 { /// • anywhere else (a globally-installed package, an odd mount) /// → `''` (absolute literal — last resort) /// `null` when the plugin ships no helpers file to wire. -fn supportHelpersExpr(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, pluginPath: []const u8) !?[]const u8 { +pub fn supportHelpersExpr(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, pluginPath: []const u8) !?[]const u8 { const helpers = util.trimSlash(try std.fmt.allocPrint(allocator, "{s}/Support/helpers.php", .{pluginPath})); if (!util.fileExists(io, helpers)) return null; @@ -1588,19 +1729,42 @@ fn makeInPlugin(allocator: std.mem.Allocator, io: Io, env: *EnvMap, kind: MakeKi return 1; }; - const studlyName = try util.studly(allocator, name); - const lowerName = try util.lower(allocator, studlyName); + // The name is the user's, and it survives verbatim. + // + // It used to go through studly()+lower(), which silently ate the + // underscores: `make:migration Demo add_widgets` wrote + // `create_addwidgets_table.php` around `$schema->create('addwidgets')` — a + // name nobody typed, describing a table nobody wanted. + const snakeName = try util.snake(allocator, name); + const suffix = switch (kind) { + .migration => "", + .seeder => "Seeder", + .factory => "Factory", + }; + // `make:seeder WidgetSeeder` means WidgetSeeder, not WidgetSeederSeeder. + const baseName = util.stripSuffix(name, suffix); + const studlyName = try util.studly(allocator, baseName); + const migrationName = try migrationFileName(allocator, snakeName); + const tableName = try migrationTable(allocator, snakeName); const tpl_src = switch (kind) { - .migration => "migration.php", + // A name that alters gets a body that alters. Scaffolding create() for + // `add_widgets_to_orders` generated code that fails on any environment + // where `orders` already exists — which is all of them. + .migration => if (std.mem.startsWith(u8, migrationName, "create_")) + "migration.php" + else + "migration_alter.php", .seeder => "seeder.php", .factory => "factory.php", }; const dest_rel = switch (kind) { - .migration => try std.fmt.allocPrint(allocator, "database/migrations/{s}_create_{s}_table.php", .{ try util.timestampPrefix(allocator), lowerName }), + .migration => try std.fmt.allocPrint(allocator, "database/migrations/{s}_{s}.php", .{ try util.timestampPrefix(allocator), migrationName }), .seeder => try std.fmt.allocPrint(allocator, "database/seeders/{s}Seeder.php", .{studlyName}), .factory => try std.fmt.allocPrint(allocator, "database/factories/{s}Factory.php", .{studlyName}), }; + // The migration template names a TABLE; the others name a class. + const lowerName = if (kind == .migration) tableName else try util.lower(allocator, studlyName); const folderPath = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ chosen.dir, chosen.name }); const dest = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ folderPath, dest_rel }); @@ -1639,12 +1803,56 @@ fn makeInPlugin(allocator: std.mem.Allocator, io: Io, env: *EnvMap, kind: MakeKi return 0; } + +/// Verbs a migration name can start with. A name beginning with one already +/// says what it does, so it is used as written; anything else is wrapped as +/// `create__table`, which is what a bare noun ("widgets") means. +const migration_verbs = [_][]const u8{ + "create_", "add_", "update_", "drop_", "remove_", "rename_", "alter_", "change_", "modify_", +}; + +fn startsWithVerb(snakeName: []const u8) bool { + for (migration_verbs) |v| { + if (std.mem.startsWith(u8, snakeName, v)) return true; + } + return false; +} + +/// The migration file name (no timestamp, no extension). +fn migrationFileName(allocator: std.mem.Allocator, snakeName: []const u8) ![]const u8 { + if (startsWithVerb(snakeName)) return snakeName; + return std.fmt.allocPrint(allocator, "create_{s}_table", .{snakeName}); +} + +/// The table a migration name is about. +/// +/// A best guess, and deliberately a plain one — it seeds the scaffold, and the +/// author edits it. `add_widgets_to_orders` is about `orders`, not `widgets`: +/// the thing after `_to_` is the table being changed. +fn migrationTable(allocator: std.mem.Allocator, snakeName: []const u8) ![]const u8 { + var t = snakeName; + + if (std.mem.indexOf(u8, t, "_to_")) |i| return allocator.dupe(u8, t[i + 4 ..]); + if (std.mem.indexOf(u8, t, "_from_")) |i| return allocator.dupe(u8, t[i + 6 ..]); + if (std.mem.indexOf(u8, t, "_on_")) |i| return allocator.dupe(u8, t[i + 4 ..]); + + for (migration_verbs) |v| { + if (std.mem.startsWith(u8, t, v)) { + t = t[v.len..]; + break; + } + } + if (std.mem.endsWith(u8, t, "_table")) t = t[0 .. t.len - "_table".len]; + + return allocator.dupe(u8, if (t.len > 0) t else snakeName); +} + // ── help ────────────────────────────────────────────────────────────────────── fn printHelp() void { prompt.intro("hkm plugins"); prompt.section("Usage"); - prompt.item("hkm plugins [path|name]", "show the plugins/modules a project enables"); + prompt.item("hkm plugins [path|name]", "show the plugins/modules a project enables (aliases: list/ls/analyze/status)"); prompt.item("hkm plugins verify [proj]", "audit enabled plugins: wiring, deps + copied assets/views/migrations/configs"); prompt.item("hkm plugins recover [proj]", "rebuild var/plugin-assets.json from on-disk assets (aliases: rebuild/reindex)"); prompt.item("hkm plugins enable [proj]", "wire a plugin into the project bootstrap"); @@ -1655,11 +1863,17 @@ fn printHelp() void { prompt.item("hkm plugins delete [proj]", "delete a plugin folder from disk"); prompt.blank(); prompt.section("From git"); - prompt.item("hkm plugins install [proj]", "fetch a plugin from its git remote (aliases: fetch/get)"); + prompt.item("hkm plugins install [proj]", "install every plugin the project declares but does not have yet"); + prompt.item("hkm plugins install --latest", "…and move every one to its newest release (alias: --upgrade)"); + prompt.item("hkm plugins install [proj]", "fetch one plugin from its git remote (aliases: fetch/get)"); + prompt.item("hkm plugins install [proj]", "…or from any remote directly — a fork, a mirror, an unregistered plugin"); prompt.item("hkm plugins uninstall [proj]", "delete an installed plugin and drop it from the lock"); prompt.item("hkm plugins versions ", "list the releases available on the remote"); prompt.item("hkm plugins outdated [proj]", "show which locked plugins have a newer release"); prompt.item("hkm plugins lock [proj]", "restore every plugin at the exact version plugins.lock.json records"); + prompt.item("hkm plugins prune [proj]", "delete shared-store versions no project pins any more (alias: gc)"); + prompt.item("hkm plugins domains [proj]", "show which plugin provides each domain a requires[] can name"); + prompt.item("hkm plugins store", "show the global plugin cache (--set=, --migrate; alias: cache)"); prompt.item("hkm plugins make:migration ", "add a migration INTO a plugin (not published)"); prompt.item("hkm plugins make:seeder|make:factory ", "add a seeder/factory into a plugin"); prompt.blank(); @@ -1671,6 +1885,10 @@ fn printHelp() void { prompt.item("--version=", "install/update to a specific release instead of the newest"); prompt.item("--force", "overwrite a plugin working copy that has uncommitted changes"); prompt.item("--full", "clone full history instead of a shallow --depth 1"); + prompt.item("--no-verify", "install without running the plugin's test suite first"); + prompt.item("--verify", "run the suite even for a batch install, where it is off by default"); + prompt.item("--no-deps", "install only the named plugin — skip the plugins its requires[] needs"); + prompt.item("--latest", "restore: ignore the locked versions and take the newest release of each"); prompt.item("--fix, -f", "verify: publish missing assets + wire Support requires"); prompt.item("--help, -h", "show this help"); prompt.blank(); @@ -1686,7 +1904,7 @@ fn printHelp() void { prompt.item("upgrade", "split-safe: a migration moved to a new plugin keeps its data; only manifest ownership transfers, no DDL re-runs (aliases: reconcile/migrate)"); prompt.item("create", "scaffolds a complete plugin (config, migration, seeder, factory, view)"); prompt.item("Support helpers", "a plugin's Support/helpers.php is require_once'd in the bootstrap on enable, removed on disable"); - prompt.item("aliases", "enable=add/on · disable=remove/off · create=new/make · delete=del/rm"); + prompt.item("aliases", "enable=add/on · disable=remove/off · create=new/make/scaffold · delete=del/rm/destroy"); prompt.blank(); prompt.section("Resolution"); prompt.item("path", "a directory holding proj.json"); @@ -1706,32 +1924,683 @@ fn installCmd( plugin: []const u8, target: []const u8, opts: installer.Options, + with_deps: bool, ) !u8 { const root = (try requireRoot(allocator, io, env, target)) orelse return 1; prompt.intro("hkm plugins install"); prompt.ok(try std.fmt.allocPrint(allocator, "project {s}", .{root})); - const remote = try pregistry.remoteFor(allocator, env, plugin); + // A URL in place of a name installs straight from that remote. The display + // name is only a first guess taken from the repository — the installer + // replaces it with whatever the plugin's module.json declares. + const by_url = pregistry.isRemoteUrl(plugin); + var call_opts = opts; + const name = if (by_url) blk: { + call_opts.remote = std.mem.trim(u8, plugin, " \t\r\n"); + break :blk pregistry.nameFromRemote(allocator, plugin) catch { + prompt.err(try std.fmt.allocPrint( + allocator, + "Could not work out a plugin name from '{s}' — it has no repository name in it.", + .{plugin}, + )); + return 2; + }; + } else plugin; + + const remote = if (by_url) call_opts.remote else try pregistry.remoteFor(allocator, env, name); prompt.muted(try std.fmt.allocPrint(allocator, "remote {s}", .{remote})); + if (by_url) { + prompt.muted(try std.fmt.allocPrint(allocator, "name {s} (from the repository)", .{name})); + // Where it lands is the one thing a URL install changes silently, and + // it decides which composer resolves the plugin. + if (!pregistry.remoteIsFirstParty(env, remote)) { + prompt.muted("target this project's plugins/ (not a first-party remote)"); + } + } + + const outcome = try installer.install(allocator, io, env, root, name, call_opts); + + // Report — and later wire in — the name the INSTALLER settled on, not the + // argument. A URL install's argument is a URL, and a plugin whose + // module.json disagreed with its repository name is now on disk under the + // module.json name; using the argument printed a name nothing has, and had + // enable try to fetch a plugin called "https://…". + const final_name = switch (outcome) { + .installed, .up_to_date, .linked => |e| e.name, + .updated => |u| u.to.name, + .refused => name, + }; + + const code = try installer.report(allocator, final_name, outcome, opts.dry_run); - const outcome = try installer.install(allocator, io, env, root, plugin, opts); - const code = try installer.report(allocator, plugin, outcome, opts.dry_run); + // ── Its dependencies ──────────────────────────────────────────────────── + // + // A plugin declares what it needs as DOMAINS, and a plugin whose domains + // are not on disk installs cleanly and then fails at boot — the same + // class of failure as a project scaffolded without its plugins. Fetch the + // closure now, while there is somewhere to report it. + if (with_deps and code == 0 and !opts.dry_run) { + _ = installDependencies(allocator, io, env, root, final_name, opts, null) catch |e| { + prompt.warn(try std.fmt.allocPrint( + allocator, + "could not resolve {s}'s dependencies ({s}) — run 'hkm plugins verify' to see what is missing.", + .{ final_name, @errorName(e) }, + )); + return 0; + }; + } // Only a real change touches the lock file; a dry run must leave the // project byte-identical. if (!opts.dry_run) { switch (outcome) { - .installed, .up_to_date => |e| try installer.recordInLock(allocator, io, root, e), + .installed, .up_to_date, .linked => |e| try installer.recordInLock(allocator, io, root, e), .updated => |u| try installer.recordInLock(allocator, io, root, u.to), .refused => {}, } } - if (code == 0) { - prompt.outro(if (opts.dry_run) "Dry run — nothing was written" else "Enable it with: hkm plugins enable " ++ ""); + if (code != 0 or opts.dry_run) { + if (opts.dry_run) prompt.outro("Dry run — nothing was written"); + return code; + } + + // ── Finish the job ────────────────────────────────────────────────────── + // + // An installed plugin that is not wired into the bootstrap does nothing, + // and one whose assets are not published is wired but half-present. Doing + // the whole sequence here is the difference between "downloaded" and + // "usable"; each step is reported so a partial result is visible rather + // than assumed. + return finishInstall(allocator, io, env, root, final_name); +} + +/// `hkm plugins install` with no plugin — install everything the project +/// declares but does not have. +/// +/// The gap this closes: a project cloned from git carries its bootstrap and its +/// plugins.lock.json, and nothing else. `lock` restores only what the lock +/// records, so a plugin enabled in the bootstrap but never locked was invisible +/// to it; `update` and `upgrade` operate on plugins already on disk and reported +/// "0 plugins" on a checkout with none. The only way through was `hkm plugins +/// enable ` once per plugin, relying on enable's auto-fetch. +/// +/// Two sources, and the lock wins where they overlap — a locked version is a +/// deliberate pin, and restoring it as "newest" would defeat having a lock: +/// +/// plugins.lock.json → the exact version + remote recorded +/// the bootstrap → enabled plugins the lock has never heard of, newest +fn restoreCmd( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + target: []const u8, + base: installer.Options, + with_deps: bool, + latest: bool, +) !u8 { + const root = (try requireRoot(allocator, io, env, target)) orelse return 1; + + prompt.intro("hkm plugins install"); + if (latest) { + prompt.muted("--latest: taking the newest release of every plugin, ignoring the locked versions"); + } + prompt.ok(try std.fmt.allocPrint(allocator, "project {s}", .{root})); + + const Want = struct { name: []const u8, version: []const u8, remote: []const u8, from_lock: bool }; + var wanted: std.ArrayList(Want) = .empty; + + const lock = plock.read(allocator, io, root) catch plock.Lock{}; + for (lock.entries.items) |e| { + // A local plugin is not fetched from anywhere — it IS the project's. + if (std.mem.eql(u8, e.source, "local")) continue; + try wanted.append(allocator, .{ + .name = e.name, + // An empty version means "newest allowed". Dropping the pin is the + // whole of --latest: everything else about the restore is the same. + .version = if (latest) "" else e.version, + .remote = e.remote, + .from_lock = true, + }); + } + + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + if (try readBootstrap(allocator, io, bootstrap)) |source| { + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + + for (enabled.items) |e| { + var known = false; + for (wanted.items) |w| { + if (util.eqlIgnoreCase(w.name, e.name)) known = true; + } + if (!known) try wanted.append(allocator, .{ + .name = e.name, + .version = "", + .remote = "", + .from_lock = false, + }); + } + } + + if (wanted.items.len == 0) { + prompt.warn("This project declares no plugins — nothing to install."); + prompt.muted(" plugins come from app/bootstrap/app.php and plugins.lock.json."); + prompt.outro("Nothing to do"); + return 0; + } + + // What the project can already see, in either source. + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + + var present: usize = 0; + var installed: usize = 0; + var pulled: usize = 0; // dependencies the project never listed + var pulled_names: std.ArrayList([]const u8) = .empty; + var failed: std.ArrayList([]const u8) = .empty; + // Plugins whose wiring still has to be done. Fetching one is only half the + // job: a plugin on disk that no bootstrap names is inert, and a DEPENDENCY + // that is installed-but-not-enabled fails the boot outright — the kernel + // refuses a requires[] domain no enabled module solves. + var to_wire: std.ArrayList([]const u8) = .empty; + + for (wanted.items) |w| { + const folder = try pregistry.canonicalName(allocator, w.name); + + var found = false; + for (search) |src| { + const d = srcs.dirFor(src) orelse continue; + const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ d, folder }); + if (util.dirExists(Dir.cwd(), io, fp)) found = true; + } + // --latest has to reach a plugin that is already installed — that is + // exactly the plugin it exists to move forward. + if (found and !base.force and !latest) { + present += 1; + continue; + } + + if (base.dry_run) { + prompt.muted(try std.fmt.allocPrint(allocator, "would install {s} {s}", .{ + folder, + if (w.version.len > 0) w.version else "(newest)", + })); + installed += 1; + continue; + } + + var opts = base; + opts.version = w.version; + opts.remote = w.remote; + // One classmap rebuild for the whole run, not one per plugin. + opts.defer_autoload = true; + + const outcome = installer.install(allocator, io, env, root, folder, opts) catch { + try failed.append(allocator, folder); + continue; + }; + switch (outcome) { + .refused => |why| { + prompt.warn(why); + try failed.append(allocator, folder); + }, + .installed, .up_to_date, .linked, .updated => { + _ = try installer.report(allocator, folder, outcome, false); + switch (outcome) { + .installed, .up_to_date, .linked => |e| try installer.recordInLock(allocator, io, root, e), + .updated => |u| try installer.recordInLock(allocator, io, root, u.to), + .refused => {}, + } + installed += 1; + try to_wire.append(allocator, folder); + }, + } + } + + // Dependencies, for EVERY declared plugin — not only the ones just fetched. + // + // Scoping this to fresh installs meant a project whose plugins were all + // present never had its dependency graph checked at all. That is precisely + // when it matters: testpp had every plugin it listed, and still could not + // boot, because Tenancy's ROUTES require http.pageflow and nothing had ever + // gone looking for it. + if (!base.dry_run and with_deps) { + var dep_base = base; + dep_base.defer_autoload = true; + for (wanted.items) |w| { + const folder = try pregistry.canonicalName(allocator, w.name); + pulled += installDependencies(allocator, io, env, root, folder, dep_base, &pulled_names) catch 0; + } + } + + // Everything is on disk; make it visible to PHP, once. + if (!base.dry_run and (installed > 0 or pulled > 0)) { + installer.refreshAllAutoload(allocator, io, env, root); + } + + // Then wire it in — for EVERY plugin the project declares, not only the + // ones just downloaded. + // + // "Nothing to download" and "nothing to do" are different states. A project + // can have every plugin on disk and still not boot, because a dependency + // was fetched but never added to the bootstrap; scoping this to fresh + // installs meant re-running the command on such a project reported success + // and changed nothing. enable is idempotent — a plugin whose whole closure + // is already wired costs one no-op — so running it over everything is both + // cheap and the only way this command can promise a runnable project. + if (!base.dry_run) { + prompt.section("Wiring into the bootstrap"); + for (wanted.items) |w| { + const folder = try pregistry.canonicalName(allocator, w.name); + wirePlugin(allocator, io, env, root, folder) catch {}; + } + for (to_wire.items) |name| { + wirePlugin(allocator, io, env, root, name) catch {}; + } + // Anything the dependency walk pulled in is on disk but not yet wired. + for (pulled_names.items) |name| { + wirePlugin(allocator, io, env, root, name) catch {}; + } + + // Assets and UI once, after all the wiring — not per plugin. + plugin_assets.publishEnabled(allocator, io, env, root) catch { + prompt.warn("assets could not be published — run: hkm plugins update"); + }; + } + + // A plugin's Support/helpers.php defines global functions its own code + // calls; nothing autoloads a bare function file, so an unwired one is an + // undefined-function fatal at the first call. enable wires it for plugins + // it newly enables — this catches the ones that were already enabled and + // never had it wired. + if (!base.dry_run) { + _ = healSupportRequires(allocator, io, env, root, false) catch 0; + } + + if (present > 0) { + prompt.muted(try std.fmt.allocPrint(allocator, "{d} already installed", .{present})); + } + + if (failed.items.len > 0) { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{d} plugin(s) could not be installed — the project will not boot until they are:", + .{failed.items.len}, + )); + for (failed.items) |name| { + prompt.muted(try std.fmt.allocPrint(allocator, " hkm plugins install {s}", .{name})); + } + prompt.outro(try std.fmt.allocPrint(allocator, "{d} installed, {d} failed", .{ installed, failed.items.len })); + return 1; + } + + if (base.dry_run) { + prompt.outro("Dry run — nothing was written"); + return 0; + } + + if (installed == 0) { + prompt.outro("Everything this project declares is already installed"); + return 0; + } + + if (pulled > 0) { + // Counted separately because they are not what was asked for: they are + // what the declared plugins turned out to need. + prompt.outro(try std.fmt.allocPrint( + allocator, + "{d} plugin(s) installed, plus {d} pulled in as dependencies", + .{ installed, pulled }, + )); + return 0; + } + prompt.outro(try std.fmt.allocPrint(allocator, "{d} plugin(s) installed", .{installed})); + return 0; +} + +/// Install everything `folder` declares in its requires[], transitively. +/// +/// Breadth-first over a queue rather than recursion, so a dependency cycle +/// costs a `seen` lookup instead of a stack overflow — and plugins DO form +/// long chains here (OAuth2 → Auth → User → Database, Crypto, Mail, …). +/// +/// Returns the number of plugins installed. Domains that resolve to nothing are +/// reported rather than failed on: a requires[] entry with no provider is +/// usually satisfied by a kernel port bound in withPorts(), which is not +/// something to fetch. +fn installDependencies( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + folder: []const u8, + base: installer.Options, + pulled_out: ?*std.ArrayList([]const u8), +) !usize { + var queue: std.ArrayList([]const u8) = .empty; + try queue.append(allocator, folder); + + var seen: std.ArrayList([]const u8) = .empty; + try seen.append(allocator, folder); + + var unresolved: std.ArrayList([]const u8) = .empty; + var installed: usize = 0; + var announced = false; + + var head: usize = 0; + while (head < queue.items.len) : (head += 1) { + const current = queue.items[head]; + + // Re-discovered each round: the previous iteration installed plugins, + // so a domain unresolvable a moment ago may now be answered by disk. + const srcs = try sources.discoverSources(allocator, io, env, root); + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, &.{ .project, .kernel }, &cat); + + const prov = deps.findByName(cat.items, current) orelse continue; + + for (prov.requires) |req| { + const domain = req.domain; + // Already provided by something on disk: nothing to fetch. + if (deps.providerForDomain(cat.items, domain) != null) continue; + + var overridden = false; + const hit = domains.resolveRequirement(cat.items, req, &overridden) orelse { + if (!util.contains(unresolved.items, domain)) { + try unresolved.append(allocator, domain); + } + continue; + }; + if (util.contains(seen.items, hit.folder)) continue; + try seen.append(allocator, hit.folder); + + if (!announced) { + prompt.section("Dependencies"); + announced = true; + } + prompt.muted(try std.fmt.allocPrint( + allocator, + "{s} ← needed for {s}{s}", + .{ + hit.folder, + domain, + if (hit.origin == .declared) " (repo declared by the plugin)" else "", + }, + )); + if (overridden) { + // Never silent: the manifest asked for one repository and it is + // being fetched from another. + prompt.muted(try std.fmt.allocPrint( + allocator, + " ignoring the declared repo — {s} is a platform domain, provided by {s}", + .{ domain, hit.folder }, + )); + } + + var dep_opts = base; + // Batched: one classmap rebuild after the closure, not one per + // dependency. Left alone when the CALLER is already batching. + dep_opts.defer_autoload = true; + // The root's VERSION does not carry to a different plugin — it would + // ask for a tag that does not exist there. A requirement that names + // its own ref does apply. + dep_opts.version = hit.version; + // Likewise the remote: resolved from the dependency's own name, + // unless the requirement declared where to get it. + dep_opts.remote = hit.repo; + + const outcome = installer.install(allocator, io, env, root, hit.folder, dep_opts) catch |e| { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{s}: could not be installed ({s}).", + .{ hit.folder, @errorName(e) }, + )); + continue; + }; + + switch (outcome) { + .refused => |why| prompt.warn(why), + .installed, .up_to_date, .linked, .updated => { + _ = try installer.report(allocator, hit.folder, outcome, false); + switch (outcome) { + .installed, .up_to_date, .linked => |e| try installer.recordInLock(allocator, io, root, e), + .updated => |u| try installer.recordInLock(allocator, io, root, u.to), + .refused => {}, + } + installed += 1; + if (pulled_out) |out| try out.append(allocator, hit.folder); + // Its own requires[] are now in scope. + try queue.append(allocator, hit.folder); + }, + } + } + } + + // Only when this call owns the batch — a caller that set defer_autoload is + // installing more and will dump once itself. + if (installed > 0 and !base.defer_autoload) { + installer.refreshAllAutoload(allocator, io, env, root); + } + + if (unresolved.items.len > 0) { + // Not an error. Ports are bound in withPorts() and have no plugin to + // fetch — but a genuinely missing third-party plugin looks identical + // from here, so name them and let the reader judge. + prompt.muted(""); + prompt.muted("Not provided by any known plugin — kernel ports, or plugins to install by name/URL:"); + for (unresolved.items) |d| { + prompt.muted(try std.fmt.allocPrint(allocator, " {s}", .{d})); + } + } + + return installed; +} + +/// Wire every enabled plugin's `Support/helpers.php` require that is missing. +/// +/// A plugin's helpers file defines global functions its OWN code and the +/// project's code call directly (`__()`, `vite()`, `storage_config()`). Nothing +/// autoloads a plain function file — composer's `files` entry only covers +/// packages, and these plugins are linked in, not required as packages — so it +/// has to be `require_once`'d from the bootstrap or every call to it is an +/// undefined-function fatal. A plugin enabled before it shipped helpers, or +/// enabled by a path that predates the wiring, ends up exactly there: present, +/// loaded, and broken at the first helper call. +/// +/// Returns how many were wired (or would be, when `dry_run`). +pub fn healSupportRequires( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + dry_run: bool, +) !usize { + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + const source = (try readBootstrap(allocator, io, bootstrap)) orelse return 0; + + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + if (enabled.items.len == 0) return 0; + + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + + var out = source; + var wired: usize = 0; + + for (enabled.items) |e| { + var path: ?[]const u8 = null; + for (search) |src| { + const d = srcs.dirFor(src) orelse continue; + const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ d, e.name }); + if (util.dirExists(Dir.cwd(), io, fp)) { + path = fp; + break; + } + } + const pp = path orelse continue; + + const expr = (try supportHelpersExpr(allocator, io, env, root, pp)) orelse continue; + const woven = try boot.insertSupportRequire(allocator, out, e.name, expr); + if (woven.ptr == out.ptr) continue; // already wired + + out = woven; + wired += 1; + const verb = if (dry_run) "Would wire" else "Wired"; + prompt.ok(try std.fmt.allocPrint(allocator, "{s} Support/helpers.php for {s}", .{ verb, e.name })); + prompt.muted(try std.fmt.allocPrint(allocator, " + require_once {s}", .{expr})); + } + + if (wired > 0 and !dry_run) { + try Dir.cwd().writeFile(io, .{ .sub_path = bootstrap, .data = out }); + } + return wired; +} + +/// Wire a freshly installed plugin in: enable it, publish its assets, federate +/// its UI. Failures downgrade to warnings — the plugin IS installed, and a +/// missing UI mirror should not read as a failed install. +/// Does this plugin's module.json say it must be registered on every request? +/// +/// A plugin whose pipeline stage runs globally needs its bindings present +/// globally. Enabling such a plugin on-demand yields a project that installs, +/// boots, and then throws on the first request — a failure three steps removed +/// from its cause. `"activation": "essential"` moves that knowledge into the +/// plugin, where it is known, instead of the user's head. +fn declaresEssential(allocator: std.mem.Allocator, io: Io, dir: []const u8, name: []const u8) bool { + const meta = (sources.readModuleMeta(allocator, io, dir, name) catch return false) orelse return false; + const a = meta.activation orelse return false; + return util.eqlIgnoreCase(std.mem.trim(u8, a, " \t\r\n"), "essential"); +} + +/// Wire ONE plugin and its unmet requires[] closure into the bootstrap. +/// +/// Split out of finishInstall so a batch can wire many plugins and then publish +/// assets ONCE. Calling the full finish per plugin re-published every enabled +/// plugin's assets each time — quadratic, for a result identical to doing it +/// once at the end. +/// +/// Re-reads the bootstrap on every call: the previous plugin's wiring changed +/// it, and enabling against a stale copy would drop those edits. +fn wirePlugin( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + plugin: []const u8, +) !void { + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + const source = (try readBootstrap(allocator, io, bootstrap)) orelse return; + + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, search, &cat); + + var matches: std.ArrayList(Located) = .empty; + try sources.locate(allocator, io, srcs, plugin, search, &matches); + const located = sources.chooseLocated(allocator, matches.items); + + _ = enableWithDeps( + allocator, io, env, root, bootstrap, source, + cat.items, enabled.items, located, plugin, false, false, + ) catch |e| { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{s}: could not be enabled ({t}) — run: hkm plugins enable {s}", + .{ plugin, e, plugin }, + )); + }; +} + +fn finishInstall( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + plugin: []const u8, +) !u8 { + const bootstrap = try std.fmt.allocPrint(allocator, "{s}/app/bootstrap/app.php", .{root}); + const source = (try readBootstrap(allocator, io, bootstrap)) orelse { + prompt.warn("No app/bootstrap/app.php — installed, but not enabled."); + return 0; + }; + + var aliases: std.ArrayList(boot.Alias) = .empty; + try boot.collectAliases(allocator, source, &aliases); + var enabled: std.ArrayList(Enabled) = .empty; + try boot.collectEnabled(allocator, source, aliases.items, &enabled); + + // Run the enable pass even when the plugin ITSELF is already wired. + // + // Skipping it on that basis meant a plugin listed in the bootstrap never had + // its requires[] closure resolved: `hkm plugins install` fetched Tenancy's + // Database and I18n, put them on disk, and left the bootstrap naming only + // Tenancy — a project that boots straight into "requires a domain no + // enabled module solves". Being enabled says nothing about whether what it + // DEPENDS on is. + // + // enableWithDeps is already the right shape for this: it computes the + // unmet closure and reports "already enabled" only when the plugin AND + // everything under it is wired, so the call costs nothing when there is + // nothing to do. + { + const srcs = try sources.discoverSources(allocator, io, env, root); + const search = &[_]Source{ .project, .kernel }; + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, search, &cat); + + var matches: std.ArrayList(Located) = .empty; + try sources.locate(allocator, io, srcs, plugin, search, &matches); + const located = sources.chooseLocated(allocator, matches.items); + + _ = enableWithDeps( + allocator, io, env, root, bootstrap, source, + cat.items, enabled.items, located, plugin, false, false, + ) catch |e| { + prompt.warn(try std.fmt.allocPrint( + allocator, + "installed, but could not be enabled ({t}) — run: hkm plugins enable {s}", + .{ e, plugin }, + )); + return 0; + }; + } + + plugin_assets.publishEnabled(allocator, io, env, root) catch { + prompt.warn("assets could not be published — run: hkm plugins update"); + }; + + syncPluginUi(allocator, io, env, root, plugin); + + prompt.outro("Installed, enabled, assets published"); + return 0; +} + +/// Mirror the plugin's ui/ into the project frontend, when it ships one. +fn syncPluginUi(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8, plugin: []const u8) void { + var uis: std.ArrayList(plugin_ui.UiPlugin) = .empty; + plugin_ui.discover(allocator, io, env, root, &uis) catch return; + + for (uis.items) |u| { + if (!util.eqlIgnoreCase(u.name, plugin)) continue; + if (u.linked) return; // a live symlink must not be overwritten by a copy + const n = plugin_ui.syncPlugin(allocator, io, root, u, false) catch return; + prompt.ok(std.fmt.allocPrint(allocator, "ui {s} → {s} ({d} file(s))", .{ u.name, u.alias, n }) catch return); + plugin_ui.writeGlue(allocator, io, root, uis.items) catch {}; + return; } - return code; } /// `hkm plugins uninstall [proj]` — delete the plugin folder and drop @@ -1748,41 +2617,71 @@ fn uninstallCmd( force: bool, ) !u8 { const root = (try requireRoot(allocator, io, env, target)) orelse return 1; - const dir = try installer.targetDir(allocator, root, plugin); + + // Canonical folder, not the raw argument: `install crypto` creates Crypto, + // so `uninstall crypto` has to look for Crypto or it finds nothing. + const folder = try pregistry.canonicalName(allocator, plugin); prompt.intro("hkm plugins uninstall"); - if (!util.dirExists(Dir.cwd(), io, dir)) { - prompt.warn(try std.fmt.allocPrint(allocator, "{s} is not installed in this project.", .{plugin})); + // What this project actually has is the ENTRY under its own plugins/ — + // usually a symlink into the shared store. Looking at the store path + // directly (as this used to) reported "not installed" for every plugin + // installed the modern way, while the link and the lock entry sat right + // there. + const link = try std.fs.path.join(allocator, &.{ root, "plugins", folder }); + + var lock = try plock.read(allocator, io, root); + const locked = lock.find(folder); + + if (!util.dirExists(Dir.cwd(), io, link) and locked == null) { + prompt.warn(try std.fmt.allocPrint(allocator, "{s} is not installed in this project.", .{folder})); return 0; } - // Uncommitted work in a plugin folder is usually a local fix in progress. - if (!force and pgit.isRepo(io, dir, allocator) and pgit.isDirty(allocator, io, env, dir)) { + // A REAL directory here (not a link) is either a third-party plugin or a + // working copy someone is editing — worth the dirty check. A store link is + // a pristine clone, so the check cannot fire on it. + // The dirty check only makes sense for a REAL directory here — a + // third-party plugin, or a working copy someone is editing. A symlink + // points at a managed store copy, which install deliberately strips of + // tests/ and vendor/ — so `git status` there always reports deletions and + // the check would refuse EVERY uninstall unless forced. + const managed = util.isSymlink(io, link); + if (!force and !managed and pgit.isRepo(io, link, allocator) and pgit.isDirty(allocator, io, env, link)) { prompt.err(try std.fmt.allocPrint( allocator, "{s} has uncommitted local changes. Commit or stash them, or pass --force to delete anyway.", - .{plugin}, + .{folder}, )); return 1; } if (dry_run) { - prompt.muted(try std.fmt.allocPrint(allocator, "would delete {s}", .{dir})); + prompt.muted(try std.fmt.allocPrint(allocator, "would remove {s}", .{link})); + if (locked) |e| prompt.muted(try std.fmt.allocPrint(allocator, "would drop lock entry {s} {s}", .{ e.name, e.version })); + prompt.muted("the shared store copy is kept — other projects may pin that version (hkm plugins prune)"); prompt.outro("Dry run — nothing was written"); return 0; } - Dir.cwd().deleteTree(io, dir) catch { - prompt.err(try std.fmt.allocPrint(allocator, "could not delete {s}", .{dir})); - return 1; + // deleteFile first: deleteTree on a SYMLINK would follow it and delete the + // shared store copy every other project depends on. + Dir.cwd().deleteFile(io, link) catch { + Dir.cwd().deleteTree(io, link) catch { + prompt.err(try std.fmt.allocPrint(allocator, "could not remove {s}", .{link})); + return 1; + }; }; - var lock = try plock.read(allocator, io, root); - _ = lock.remove(plugin); + _ = lock.remove(folder); try plock.write(allocator, io, root, &lock, banner.version()); - prompt.ok(try std.fmt.allocPrint(allocator, "removed {s}", .{plugin})); + // The project's autoloader still lists the old path until it is rebuilt. + installer.refreshAutoload(allocator, io, env, try std.fs.path.join(allocator, &.{ root, "plugins" })); + + prompt.ok(try std.fmt.allocPrint(allocator, "removed {s}", .{folder})); + prompt.muted("the shared store copy is kept for other projects — reclaim it with: hkm plugins prune"); prompt.outro("It may still be wired in the bootstrap — run: hkm plugins disable"); return 0; } @@ -1795,7 +2694,10 @@ fn versionsCmd(allocator: std.mem.Allocator, io: Io, env: *EnvMap, plugin: []con return 1; } - const remote = try pregistry.remoteFor(allocator, env, plugin); + const remote = if (pregistry.isRemoteUrl(plugin)) + std.mem.trim(u8, plugin, " \t\r\n") + else + try pregistry.remoteFor(allocator, env, plugin); prompt.intro(try std.fmt.allocPrint(allocator, "Releases of {s}", .{plugin})); prompt.muted(try std.fmt.allocPrint(allocator, "remote {s}", .{remote})); @@ -1866,7 +2768,11 @@ fn outdatedCmd(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []con prompt.outro("Everything is on its latest release"); return 0; } - prompt.outro(try std.fmt.allocPrint(allocator, "{d} plugin(s) behind — update with: hkm plugins install ", .{behind})); + prompt.note(""); + prompt.muted("move them all forward: hkm plugins install --latest"); + prompt.muted("or just one: hkm plugins install "); + prompt.muted("or pin one exactly: hkm plugins install --version=vX.Y.Z"); + prompt.outro(try std.fmt.allocPrint(allocator, "{d} plugin(s) behind", .{behind})); return 0; } @@ -1908,6 +2814,10 @@ fn lockCmd( .dry_run = dry_run, .force = base.force, .full = base.full, + // Restore from where it actually came from. Re-deriving the remote + // from the name would send a URL-installed plugin to the registry's + // guess instead — a different repository, at the same version. + .remote = e.remote, }); if ((try installer.report(allocator, e.name, outcome, dry_run)) != 0) failed += 1; } @@ -1919,3 +2829,336 @@ fn lockCmd( prompt.outro(if (dry_run) "Dry run — nothing was written" else "Project matches plugins.lock.json"); return 0; } + +/// `hkm plugins store` — where the global plugin cache is, and moving it. +/// +/// One download per (plugin, version, origin), shared by every project: project +/// A fetching Auth v1.2.0 pays for it once, project B links at what is already +/// there. This command is how that location is inspected, relocated, and how +/// caches left in older layouts are folded into it. +fn storeCmd( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + set_to: []const u8, + migrate: bool, + dry_run: bool, +) !u8 { + prompt.intro("hkm plugins store"); + + const kernel_fallback = blk: { + const p = installer.pluginsRoot(allocator, io, env, ".") catch break :blk "."; + break :blk util.parentOf(p) orelse "."; + }; + + if (set_to.len > 0) { + const abs = util.trimSlash(std.mem.trim(u8, set_to, " \t\r\n")); + if (abs.len == 0 or abs[0] != '/') { + prompt.err("--set needs an ABSOLUTE path — the store is shared by projects in different directories."); + return 2; + } + if (dry_run) { + prompt.muted(try std.fmt.allocPrint(allocator, "would set HKM_PLUGIN_STORE={s}", .{abs})); + prompt.outro("Dry run — nothing was written"); + return 0; + } + Dir.cwd().createDirPath(io, abs) catch { + prompt.err(try std.fmt.allocPrint(allocator, "could not create {s}", .{abs})); + return 1; + }; + userconfig.set(allocator, io, env, "HKM_PLUGIN_STORE", abs) catch { + prompt.err("could not write the config file."); + return 1; + }; + prompt.ok(try std.fmt.allocPrint(allocator, "store set to {s}", .{abs})); + prompt.muted(" existing caches stay where they are — fold them in with: hkm plugins store --migrate"); + // Read back through the same path resolution the installer uses, so + // what is reported is what will actually be used. + try env.put("HKM_PLUGIN_STORE", abs); + } + + const root_dir = try pstore.root(allocator, env, kernel_fallback); + prompt.ok(try std.fmt.allocPrint(allocator, "store {s}", .{root_dir})); + prompt.muted(try std.fmt.allocPrint(allocator, "layout /-", .{})); + + if (migrate) { + const moved = try migrateStores(allocator, io, env, root_dir, kernel_fallback, dry_run); + if (moved == 0) prompt.muted("nothing to migrate — no cache found in an older location."); + } + + // Contents. + var plugins: usize = 0; + var versions: usize = 0; + if (util.dirExists(Dir.cwd(), io, root_dir)) { + var d = Dir.cwd().openDir(io, root_dir, .{ .iterate = true }) catch { + prompt.outro("store is not readable"); + return 1; + }; + defer d.close(io); + var it = d.iterate(); + while (try it.next(io)) |e| { + if (e.kind != .directory) continue; + plugins += 1; + const pd = try std.fs.path.join(allocator, &.{ root_dir, e.name }); + var vd = Dir.cwd().openDir(io, pd, .{ .iterate = true }) catch continue; + defer vd.close(io); + var vit = vd.iterate(); + while (try vit.next(io)) |v| { + if (v.kind == .directory) versions += 1; + } + } + } + + prompt.blank(); + prompt.item("cached", try std.fmt.allocPrint(allocator, "{d} plugin(s), {d} version(s)", .{ plugins, versions })); + prompt.muted("reclaim unreferenced versions with: hkm plugins prune"); + prompt.outro("Shared by every project on this machine"); + return 0; +} + +/// Fold caches left in older locations into the current store. +/// +/// Two layouts predate it: `/plugin-store` (when the store lived beside +/// the kernel) and `/plugin-store` (when it followed the install +/// target, so every project kept its own copy). Entries are MOVED, never +/// merged over: a destination that already exists is left alone, because the +/// two directories are the same (plugin, version, origin) and the one already +/// in place is the one projects are linked to. +fn migrateStores( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + dest_root: []const u8, + kernel_root: []const u8, + dry_run: bool, +) !usize { + var sources_list: std.ArrayList([]const u8) = .empty; + try sources_list.append(allocator, try std.fs.path.join(allocator, &.{ kernel_root, pstore.dir_name })); + if (try registry.resolvePath(allocator, io, env)) |jsonPath| { + for (try registry.list(allocator, io, jsonPath)) |e| { + try sources_list.append(allocator, try std.fs.path.join(allocator, &.{ e.path, pstore.dir_name })); + } + } + + var moved: usize = 0; + for (sources_list.items) |src| { + if (std.mem.eql(u8, src, dest_root)) continue; + if (!util.dirExists(Dir.cwd(), io, src)) continue; + + prompt.section(try std.fmt.allocPrint(allocator, "migrating {s}", .{src})); + + var d = Dir.cwd().openDir(io, src, .{ .iterate = true }) catch continue; + defer d.close(io); + var it = d.iterate(); + while (try it.next(io)) |plugin| { + if (plugin.kind != .directory) continue; + const from_plugin = try std.fs.path.join(allocator, &.{ src, plugin.name }); + var vd = Dir.cwd().openDir(io, from_plugin, .{ .iterate = true }) catch continue; + defer vd.close(io); + var vit = vd.iterate(); + while (try vit.next(io)) |v| { + if (v.kind != .directory) continue; + const from = try std.fs.path.join(allocator, &.{ from_plugin, v.name }); + const to_plugin = try std.fs.path.join(allocator, &.{ dest_root, plugin.name }); + const to = try std.fs.path.join(allocator, &.{ to_plugin, v.name }); + + if (util.dirExists(Dir.cwd(), io, to)) { + prompt.muted(try std.fmt.allocPrint(allocator, " {s}/{s} already cached — left in place", .{ plugin.name, v.name })); + continue; + } + if (dry_run) { + prompt.muted(try std.fmt.allocPrint(allocator, " would move {s}/{s}", .{ plugin.name, v.name })); + moved += 1; + continue; + } + Dir.cwd().createDirPath(io, to_plugin) catch {}; + Dir.cwd().rename(from, Dir.cwd(), to, io) catch { + prompt.warn(try std.fmt.allocPrint(allocator, " could not move {s}/{s}", .{ plugin.name, v.name })); + continue; + }; + prompt.ok(try std.fmt.allocPrint(allocator, " moved {s}/{s}", .{ plugin.name, v.name })); + moved += 1; + } + } + } + + if (moved > 0 and !dry_run) { + // The project links point at the OLD paths and are now dangling. + prompt.muted(""); + prompt.muted("project links still point at the old paths — repoint them with:"); + prompt.muted(" hkm plugins lock (in each project)"); + } + return moved; +} + +/// `hkm plugins prune` — drop shared-store versions nothing pins any more. +/// +/// The store keeps one copy per (plugin, version) so projects can share a +/// download and pin independently. Nothing ever removed from it, so every +/// version any project EVER used accumulated forever. This is the other half of +/// that design. +/// +/// A version is kept if ANY known project's plugins.lock.json still names it. +/// "Known" means the kernel registry plus, if given, the project argument — so a +/// project that was never registered is invisible here. That is why an +/// unreadable or missing lock aborts rather than being treated as "pins +/// nothing": guessing wrong deletes a version a live project depends on. +fn pruneCmd(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8, dry_run: bool) !u8 { + prompt.intro("hkm plugins prune"); + + const plugins_dir = try installer.pluginsRoot(allocator, io, env, if (target.len > 0) target else "."); + const kernel_root = util.parentOf(plugins_dir) orelse "."; + const store = try pstore.root(allocator, env, kernel_root); + + prompt.muted(try std.fmt.allocPrint(allocator, "store {s}", .{store})); + + if (!util.dirExists(Dir.cwd(), io, store)) { + prompt.muted("no shared store — nothing to prune."); + return 0; + } + + // Collect every project that might pin something. + var roots: std.ArrayList([]const u8) = .empty; + if (try registry.resolvePath(allocator, io, env)) |jsonPath| { + for (try registry.list(allocator, io, jsonPath)) |e| { + try roots.append(allocator, e.path); + } + } + if (target.len > 0) { + if (try services.resolveRoot(allocator, io, env, target)) |r| try roots.append(allocator, r); + } + + if (roots.items.len == 0) { + prompt.err("no registered projects found — refusing to prune."); + prompt.muted(" every store version would look unreferenced, and pruning would delete all of them."); + prompt.muted(" register a project first (hkm discover), or pass one: hkm plugins prune "); + return 1; + } + + // Everything still pinned, as "/". + var pinned: std.ArrayList([]const u8) = .empty; + for (roots.items) |root| { + const lock = plock.read(allocator, io, root) catch continue; + for (lock.entries.items) |e| { + if (e.version.len == 0) continue; + // The exact directory name, origin hash included — a fork's copy + // must not be kept alive by the upstream's lock entry. + const key = try pstore.versionKey(allocator, e.version, e.remote); + try pinned.append(allocator, try std.fmt.allocPrint(allocator, "{s}/{s}", .{ e.name, key })); + // Entries written before origin hashing are bare versions. + if (e.remote.len > 0) { + try pinned.append(allocator, try std.fmt.allocPrint(allocator, "{s}/{s}", .{ e.name, e.version })); + } + } + } + + prompt.ok(try std.fmt.allocPrint(allocator, "{d} project(s) pin {d} version(s)", .{ roots.items.len, pinned.items.len })); + // Said out loud because it is the one way this can do damage: a project the + // kernel has never been told about pins nothing as far as prune can see, so + // its versions look free. Deleting one breaks that project's plugin links. + prompt.muted(" only registered projects are consulted — run hkm discover first if any are missing."); + + var freed: usize = 0; + var kept: usize = 0; + var names = Dir.cwd().openDir(io, store, .{ .iterate = true }) catch { + prompt.err("could not read the store."); + return 1; + }; + defer names.close(io); + + var name_it = names.iterate(); + while (try name_it.next(io)) |plugin_entry| { + if (plugin_entry.kind != .directory) continue; + + const plugin_dir = try std.fs.path.join(allocator, &.{ store, plugin_entry.name }); + var versions = Dir.cwd().openDir(io, plugin_dir, .{ .iterate = true }) catch continue; + defer versions.close(io); + + var v_it = versions.iterate(); + while (try v_it.next(io)) |v| { + if (v.kind != .directory) continue; + + const key = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ plugin_entry.name, v.name }); + if (util.contains(pinned.items, key)) { + kept += 1; + continue; + } + + const path = try std.fs.path.join(allocator, &.{ plugin_dir, v.name }); + if (dry_run) { + prompt.muted(try std.fmt.allocPrint(allocator, "would delete {s}", .{key})); + } else { + Dir.cwd().deleteTree(io, path) catch { + prompt.warn(try std.fmt.allocPrint(allocator, "could not delete {s}", .{key})); + continue; + }; + prompt.ok(try std.fmt.allocPrint(allocator, "deleted {s}", .{key})); + } + freed += 1; + } + } + + if (freed == 0) { + prompt.outro(try std.fmt.allocPrint(allocator, "nothing to prune — all {d} stored version(s) are still pinned", .{kept})); + return 0; + } + prompt.outro(try std.fmt.allocPrint( + allocator, + "{s} {d} version(s); {d} still pinned", + .{ if (dry_run) "would free" else "freed", freed, kept }, + )); + return 0; +} + +/// `hkm plugins domains` — the domain → plugin lookup, and where each entry +/// came from. +/// +/// Exists because the mapping is invisible otherwise: a plugin's requires[] +/// names domains, and nothing in a project says which plugin answers one. When +/// an install reports a domain it could not resolve, this is the table to read. +fn domainsCmd(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8) !u8 { + const root = (try services.resolveRoot(allocator, io, env, if (target.len > 0) target else ".")) orelse ""; + + prompt.intro("hkm plugins domains"); + + // Installed plugins first — their module.json is the authority, and seeing + // them separated from the built-in table is the point: one is fact, the + // other is this tool's last known good guess. + var cat: std.ArrayList(deps.Provider) = .empty; + if (root.len > 0) { + const srcs = try sources.discoverSources(allocator, io, env, root); + try deps.catalogue(allocator, io, srcs, &.{ .project, .kernel }, &cat); + prompt.ok(try std.fmt.allocPrint(allocator, "project {s}", .{root})); + } + + var installed: usize = 0; + for (cat.items) |p| { + if (p.solves == null) continue; + installed += 1; + } + + if (installed > 0) { + prompt.section("Installed — read from each plugin's module.json"); + for (cat.items) |p| { + const d = p.solves orelse continue; + prompt.item(d, p.located.name); + } + } + + prompt.section("Built in — used for plugins not installed yet"); + var seeded: usize = 0; + for (domains.seed) |m| { + // Don't repeat what disk already answered above. + if (deps.providerForDomain(cat.items, m.domain) != null) continue; + prompt.item(m.domain, m.folder); + seeded += 1; + } + if (seeded == 0) prompt.muted(" (every seeded domain is already installed)"); + + prompt.outro(try std.fmt.allocPrint( + allocator, + "{d} from disk, {d} from the built-in table", + .{ installed, seeded }, + )); + return 0; +} diff --git a/tools/src/commands/upgrade.zig b/tools/src/commands/upgrade.zig index 25a6454..52946b1 100644 --- a/tools/src/commands/upgrade.zig +++ b/tools/src/commands/upgrade.zig @@ -12,6 +12,7 @@ const banner = @import("../lib/banner.zig"); const kernel = @import("../lib/kernel.zig"); const run_cmd = @import("run.zig"); const util = @import("../lib/util.zig"); +const userconfig = @import("../lib/userconfig.zig"); const semver = @import("../lib/semver.zig"); const prompt = @import("../lib/prompt.zig"); @@ -104,21 +105,32 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c // Opt IN to dev / rc releases. Off by default: a pre-release must never // reach someone who did not ask for one. var include_pre = false; + // --user: install into the user's own data dir instead of the system one, + // so nothing about the kernel — including installing plugins into its + // plugins/ — ever needs root. + var user_install = false; + // --local builds the checkout before copying it. Without this the tools/ + // binaries in zig-out could be older than the source being installed, so + // "install my local changes" would ship a launcher that predates them — + // the one failure mode a local test install must not have. + var build_first = true; for (args[1..]) |a| { if (std.mem.eql(u8, a, "--check") or std.mem.eql(u8, a, "-c")) check_only = true; if (std.mem.eql(u8, a, "--local") or std.mem.eql(u8, a, "-l")) from_local = true; if (std.mem.eql(u8, a, "--dry-run") or std.mem.eql(u8, a, "-n")) dry_run = true; if (std.mem.eql(u8, a, "--yes") or std.mem.eql(u8, a, "-y")) assume_yes = true; if (std.mem.eql(u8, a, "--pre")) include_pre = true; + if (std.mem.eql(u8, a, "--user") or std.mem.eql(u8, a, "-u")) user_install = true; + if (std.mem.eql(u8, a, "--no-build")) build_first = false; if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { printHelp(); return 0; } } - banner.print(); + banner.print(allocator, io, env); - if (from_local) return localUpgrade(allocator, io, env, dry_run, assume_yes); + if (from_local) return localUpgrade(allocator, io, env, dry_run, assume_yes, user_install, build_first); const current = parseVer(banner.version()); @@ -226,11 +238,20 @@ fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, la var argv = [_][]const u8{ "sudo", "apt-get", "install", "-y", tmp }; const code = run_cmd.spawnWait(io, env, &argv) catch 1; if (code != 0) { - // Fallback: dpkg then fix deps. + // Fallback: dpkg then fix deps. Both results are KEPT: with them + // discarded, an upgrade where apt AND dpkg both failed printed + // "updated" and left the old kernel installed — the user then + // debugs a version they believe they are no longer running. var dpkg = [_][]const u8{ "sudo", "dpkg", "-i", tmp }; - _ = run_cmd.spawnWait(io, env, &dpkg) catch {}; + const dpkg_code = run_cmd.spawnWait(io, env, &dpkg) catch 1; var fix = [_][]const u8{ "sudo", "apt-get", "-f", "install", "-y" }; - _ = run_cmd.spawnWait(io, env, &fix) catch {}; + const fix_code = run_cmd.spawnWait(io, env, &fix) catch 1; + if (dpkg_code != 0 and fix_code != 0) { + prompt.err("installation FAILED — the previous kernel is still in place."); + prompt.muted(try std.fmt.allocPrint(allocator, " the package is downloaded at {s}", .{tmp})); + prompt.muted(" try it by hand: sudo apt-get install -y "); + return 1; + } } }, .macos => { @@ -238,11 +259,19 @@ fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, la const root = kernelRoot(allocator, io, env) orelse "/Applications/HKM.app/Contents/Resources/opt/hkm-kernel"; const app_root = std.fs.path.dirname(std.fs.path.dirname(std.fs.path.dirname(root) orelse root) orelse root) orelse root; var untar = [_][]const u8{ "tar", "-xzf", tmp, "-C", app_root, "--strip-components=0" }; - _ = run_cmd.spawnWait(io, env, &untar) catch {}; + if ((run_cmd.spawnWait(io, env, &untar) catch 1) != 0) { + prompt.err("could not unpack the release — the previous kernel is still in place."); + prompt.muted(try std.fmt.allocPrint(allocator, " the archive is at {s}", .{tmp})); + return 1; + } const installer = try std.fs.path.join(allocator, &.{ root, "install.sh" }); if (util.fileExists(io, installer)) { var sh = [_][]const u8{ "sh", installer }; - _ = run_cmd.spawnWait(io, env, &sh) catch {}; + if ((run_cmd.spawnWait(io, env, &sh) catch 1) != 0) { + prompt.err("unpacked, but install.sh failed — the install may be half-updated."); + prompt.muted(" re-run it by hand, then check: hkm doctor"); + return 1; + } } }, .windows => { @@ -282,6 +311,8 @@ fn printHelp() void { prompt.item("--local, -l", "source the update from the local checkout instead of GitHub"); prompt.item("--dry-run, -n", "show what --local would copy, write nothing"); prompt.item("--yes, -y", "skip the confirmation prompt"); + prompt.item("--user, -u", "with --local: install into ~/.local/share/hkm/kernel (no sudo, ever)"); + prompt.item("--no-build", "with --local: skip `zig build`, install what is already in tools/zig-out"); prompt.item("--pre", "consider pre-releases (dev / rc) when checking for updates"); prompt.item("--check, -c", "check only"); prompt.item("--help, -h", "show this help"); @@ -298,18 +329,44 @@ fn printHelp() void { /// It copies the same file set a .deb ships (shipped_paths, mirroring /// bundle.sh), so the result behaves like a real install rather than a /// half-synced hybrid. -fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: bool, assume_yes: bool) !u8 { +/// `~/.local/share/hkm/kernel` (or $XDG_DATA_HOME), the user-owned kernel root. +/// +/// The system install lives under /opt and is root-owned, which means every +/// plugin install — they go into the kernel's plugins/ — needs sudo. A kernel +/// inside the user's own data directory removes that entirely, and sits beside +/// the registry hkm already keeps at ~/.local/share/hkm. +fn userKernelRoot(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + if (env.get("XDG_DATA_HOME")) |x| { + if (x.len > 0) return std.fmt.allocPrint(allocator, "{s}/hkm/kernel", .{util.trimSlash(x)}) catch null; + } + const home = env.get("HOME") orelse return null; + if (home.len == 0) return null; + return std.fmt.allocPrint(allocator, "{s}/.local/share/hkm/kernel", .{util.trimSlash(home)}) catch null; +} + +fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: bool, assume_yes: bool, user_install: bool, build_first: bool) !u8 { // SOURCE: the checkout this command is being run from or pointed at. - const src = (try kernel.resolveDevHome(allocator, io)) orelse { - prompt.err("no local kernel checkout found. Run this from inside the monorepo, or set HKM_DEV_HOME to it."); + const src = (try resolveSource(allocator, io, env)) orelse { + prompt.err("no local kernel checkout found."); + prompt.muted(" set one: hkm-config set HKM_DEV_HOME /path/to/the/checkout"); + prompt.muted(" or run this from inside it."); return 1; }; - // TARGET: the installed kernel every project on this machine resolves to. - const dest = kernelRoot(allocator, io, env) orelse { - prompt.err("could not locate an installed kernel to update. Is hkm installed (/opt/hkm-kernel)?"); - return 1; - }; + // TARGET: the user's own kernel root with --user, otherwise the installed + // one every project on this machine resolves to. + const dest = if (user_install) + userKernelRoot(allocator, env) orelse { + prompt.err("could not determine a user kernel root (no HOME / XDG_DATA_HOME)."); + return 1; + } + else + kernelRoot(allocator, io, env) orelse { + prompt.err("could not locate an installed kernel to update. Is hkm installed (/opt/hkm-kernel)?"); + return 1; + }; + + if (user_install) Dir.cwd().createDirPath(io, dest) catch {}; // Copying a checkout over itself would delete files mid-walk and leave the // only copy of the kernel in an unknown state. @@ -345,6 +402,14 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo return 1; } + // Build the checkout first, so the launcher that gets installed is the one + // built from the source being installed. + if (build_first) buildCheckout(allocator, io, env, src); + + // Untracked files under the installed paths are NOT copied — see below — + // so say which ones, before the install silently omits them. + warnUntracked(allocator, io, env, src); + // git ls-files gives exactly the TRACKED files, so build artifacts, vendor/ // and local scratch never leak into the install — the same guarantee // bundle.sh relies on. @@ -367,6 +432,7 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo if (needs_root) prompt.muted("target is not writable — using sudo"); var copied: usize = 0; + var skipped: usize = 0; // tracked by git, absent from the working tree var failed: usize = 0; var lines = std.mem.splitScalar(u8, listing.stdout, '\n'); while (lines.next()) |raw| { @@ -380,19 +446,55 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo const to = try std.fs.path.join(allocator, &.{ dest, rel_dest }); copyOne(allocator, io, env, from, to, needs_root) catch |e| { + // A file git tracks but the working tree no longer has is NOT a + // write failure — nothing was lost, because there was nothing to + // copy. Treating it as one aborted the install before the launcher + // was replaced, so a checkout with one uncommitted deletion could + // never update its own `hkm` binary: every upgrade errored, the + // stale launcher stayed, and the cause looked unrelated. + if (e == error.FileNotFound) { + skipped += 1; + if (skipped <= 10) { + prompt.muted(try std.fmt.allocPrint( + allocator, + " skipped {s} — tracked by git, missing from the working tree", + .{rel_dest}, + )); + } + continue; + } failed += 1; - // Report the FIRST failure with its cause. Counting 645 silent - // failures tells the user something went wrong and nothing about - // what, which is barely better than failing silently. - if (failed == 1) { + // Every failure, with its cause — capped so a systemic problem does + // not bury the summary. Reporting only the first meant "7 file(s) + // could not be written" alongside ONE filename, leaving the reader + // to guess whether the other six shared that cause. + if (failed <= 10) { prompt.err(try std.fmt.allocPrint(allocator, "{s}: {t}", .{ rel_dest, e })); + } else if (failed == 11) { + prompt.muted(" (further failures not listed)"); } continue; }; copied += 1; + // bin/hkm is the PHP CLI the launcher hands off to — it must stay + // executable for the same reason. + if (std.mem.eql(u8, rel_dest, "bin/hkm")) util.chmodExec(io, to); } prompt.ok(try std.fmt.allocPrint(allocator, "copied {d} file(s)", .{copied})); + + if (skipped > 0) { + // Worth saying, not worth failing over: the installed kernel matches + // the working tree, which is what --local promises. + prompt.warn(try std.fmt.allocPrint( + allocator, + "{d} file(s) are tracked by git but deleted locally — not installed.", + .{skipped}, + )); + prompt.muted(" git status --short | grep '^ D' # see them"); + prompt.muted(" git checkout -- # restore, or commit the deletion"); + } + if (failed > 0) { prompt.err(try std.fmt.allocPrint(allocator, "{d} file(s) could not be written — the install may be inconsistent.", .{failed})); return 1; @@ -401,7 +503,7 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo // The native launcher is built, not tracked, so it is copied separately — // and only when it exists, since a checkout that has never run `zig build` // has nothing to install. - installLauncher(allocator, io, env, src, needs_root); + installLauncher(allocator, io, env, src, needs_root, user_install); // vendor/ is deliberately not shipped, so dependencies are resolved against // the TARGET's PHP rather than whatever the checkout happened to resolve. @@ -413,13 +515,116 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo const code = run_cmd.spawnWait(io, env, if (needs_root) &sudo_sh else &sh) catch 1; if (code != 0) prompt.warn("install.sh reported an error — run it manually in the target to finish."); } else { - prompt.muted("no install.sh in the target — skipping composer step."); + // A --local copy carries only tracked files, and install.sh is written + // by bundle.sh at package time — so it is absent here. Run composer + // directly, or the target has no vendor/ and cannot boot. + // --no-scripts: the target is an INSTALLED kernel, never a git + // checkout, and the only scripts this package defines set up developer + // git hooks. Running them there printed "fatal: not in a git directory" + // on every install; guarding the script itself silenced the error but + // left composer echoing a long command line instead. Skipping scripts + // for a destination that cannot use them removes both, and leaves the + // script simple for the checkout where it does apply. + var composer = [_][]const u8{ "composer", "install", "--no-dev", "--optimize-autoloader", "--no-interaction", "--no-scripts", "--working-dir", dest }; + var sudo_composer = [_][]const u8{ "sudo", "composer", "install", "--no-dev", "--optimize-autoloader", "--no-interaction", "--no-scripts", "--working-dir", dest }; + const ccode = run_cmd.spawnWait(io, env, if (needs_root) &sudo_composer else &composer) catch 1; + if (ccode != 0) { + prompt.warn("composer install failed — the kernel has no vendor/ and cannot boot."); + // Name the usual cause. A bare "composer install failed" sends + // people to their network or their PHP version, when in practice it + // is almost always a cache left root-owned by an earlier + // `sudo composer` — the error surfaces as "Permission denied" on a + // .zip deep inside ~/.cache/composer. + prompt.muted(" if it said 'Permission denied' under ~/.cache/composer, the cache is root-owned:"); + prompt.muted(" sudo chown -R \"$USER\" ~/.cache/composer"); + prompt.muted(try std.fmt.allocPrint( + allocator, + " then re-run: composer install --no-dev --working-dir {s}", + .{dest}, + )); + } + } + + // A user kernel that nothing points at is inert: resolution would still + // find /opt (or nothing). Record it so every later hkm invocation — and + // therefore every plugin install — uses the root that needs no sudo. + if (user_install) { + userconfig.set(allocator, io, env, "HKM_KERNEL_HOME", dest) catch { + prompt.warn(try std.fmt.allocPrint( + allocator, + "installed, but could not record it. Add this to your shell:\n export HKM_KERNEL_HOME={s}", + .{dest}, + )); + }; + prompt.ok(try std.fmt.allocPrint(allocator, "HKM_KERNEL_HOME set to {s}", .{dest})); + prompt.muted("plugins now install there — no sudo."); } prompt.outro("Installed kernel updated from the local checkout. Verify with: hkm doctor"); return 0; } +/// The checkout to install FROM. +/// +/// Order: HKM_DEV_HOME, then the working directory, then the launcher's own +/// location. +/// +/// The last of those used to be the only one, via kernel.resolveDevHome — which +/// climbs from the EXECUTABLE's directory. Once the launcher is installed to +/// ~/.local/bin that climb can never reach a checkout, so `hkm upgrade --local` +/// failed for the very user who had just installed it, while HKM_DEV_HOME sat +/// in config.env pointing straight at the answer. +fn resolveSource(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const u8 { + if (env.get("HKM_DEV_HOME")) |h| { + const t = util.trimSlash(std.mem.trim(u8, h, " \t\r\n")); + if (t.len > 0 and kernel.isKernelDir(io, t)) return try allocator.dupe(u8, t); + } + + // Walk up from the working directory: running it from anywhere inside the + // checkout should just work. + if (env.get("PWD")) |pwd| { + var cur = util.trimSlash(pwd); + var depth: usize = 0; + while (depth < 32 and cur.len > 0) : (depth += 1) { + if (kernel.isKernelDir(io, cur)) return try allocator.dupe(u8, cur); + const parent = std.fs.path.dirname(cur) orelse break; + if (std.mem.eql(u8, parent, cur)) break; + cur = parent; + } + } + + return kernel.resolveDevHome(allocator, io); +} + +/// Run `zig build` in the checkout's tools/ before installing it. +/// +/// The version passed is `git describe`, so the installed binary reports the +/// exact commit it came from — which is the whole point of a local test +/// install. That string is deliberately NOT composer-valid for a dev checkout +/// ("1.1.0-dev.2-12-g29dccfb"), so the stamper skips composer.json and the +/// working tree stays clean; only the binary carries it. +fn buildCheckout(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []const u8) void { + const tools = std.fs.path.join(allocator, &.{ src, "tools" }) catch return; + const build_zig = std.fs.path.join(allocator, &.{ tools, "build.zig" }) catch return; + if (!util.fileExists(io, build_zig)) return; // not a checkout with tools/ + + prompt.section("Building"); + + const version = localVersion(allocator, io, env, src) orelse "0.0.0-dev"; + const dversion = std.fmt.allocPrint(allocator, "-Dversion={s}", .{version}) catch return; + + var argv = [_][]const u8{ "zig", "build", dversion, "--build-file", build_zig }; + const code = run_cmd.spawnWait(io, env, &argv) catch { + prompt.warn("zig not found — installing whatever is already in tools/zig-out."); + return; + }; + if (code != 0) { + prompt.warn("build failed — installing whatever is already in tools/zig-out."); + return; + } + prompt.ok(std.fmt.allocPrint(allocator, "built {s}", .{version}) catch "built"); +} + /// The TARGET's version, read from the composer.json that ships with it. /// /// Not banner.version(): that is the version THIS BINARY was stamped with, and @@ -474,8 +679,51 @@ fn copyOne(allocator: std.mem.Allocator, io: Io, env: *EnvMap, from: []const u8, try Dir.cwd().writeFile(io, .{ .sub_path = to, .data = data }); } +/// Name the untracked files that this install will skip. +/// +/// `--local` installs `git ls-files` output, which is the right rule: it is what +/// keeps vendor/, build output and scratch files out of the installed kernel. +/// The cost is that a NEW file — a template variant, a new source file — is +/// invisible to it, and the install silently produces a kernel without it. That +/// failure is near-impossible to read from the outside: the command reports +/// success and the feature simply is not there. +fn warnUntracked(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []const u8) void { + const res = std.process.run(allocator, io, .{ + .argv = &.{ + "git", "-C", src, "ls-files", "--others", "--exclude-standard", + "--", "src", "plugins", "projects", "templates", + "composer.json", "bin", "modules", + }, + .environ_map = env, + }) catch return; + + var shown: usize = 0; + var lines = std.mem.splitScalar(u8, res.stdout, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + if (shown == 0) { + prompt.warn("untracked files will NOT be installed — `git add` them first:"); + } + if (shown < 10) { + prompt.muted(std.fmt.allocPrint(allocator, " {s}", .{line}) catch continue); + } + shown += 1; + } + if (shown > 10) { + prompt.muted(std.fmt.allocPrint(allocator, " … and {d} more", .{shown - 10}) catch return); + } +} + /// Install the freshly built native launcher next to the one in use. -fn installLauncher(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []const u8, needs_root: bool) void { +fn installLauncher( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + src: []const u8, + needs_root: bool, + user_install: bool, +) void { const built = std.fs.path.join(allocator, &.{ src, "tools", "zig-out", "bin", "hkm" }) catch return; if (!util.fileExists(io, built)) { prompt.muted("no built launcher in tools/zig-out — run `zig build` there to update /usr/bin/hkm too."); @@ -483,17 +731,75 @@ fn installLauncher(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []co } const targets = [_][]const u8{ "hkm", "hkm-config" }; + var failed: usize = 0; + var installed_any = false; for (targets) |name| { const from = std.fs.path.join(allocator, &.{ src, "tools", "zig-out", "bin", name }) catch continue; if (!util.fileExists(io, from)) continue; - const to = std.fmt.allocPrint(allocator, "/usr/bin/{s}", .{name}) catch continue; - - if (needs_root) { + // A --user install must not write to /usr/bin: that needs root, which + // is the whole thing --user exists to avoid. ~/.local/bin is the + // conventional user-level bin dir and is already on PATH here. + const to = if (user_install) blk: { + const home = env.get("HOME") orelse continue; + const dir = std.fmt.allocPrint(allocator, "{s}/.local/bin", .{util.trimSlash(home)}) catch continue; + Dir.cwd().createDirPath(io, dir) catch {}; + break :blk std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir, name }) catch continue; + } else std.fmt.allocPrint(allocator, "/usr/bin/{s}", .{name}) catch continue; + + var copied = true; + if (needs_root and !user_install) { var cp = [_][]const u8{ "sudo", "cp", "-f", from, to }; - _ = run_cmd.spawnWait(io, env, &cp) catch continue; + const code = run_cmd.spawnWait(io, env, &cp) catch blk: { + break :blk @as(u8, 1); + }; + copied = code == 0; } else { - copyOne(allocator, io, env, from, to, false) catch continue; + // Write beside it, then rename over. + // + // A running executable cannot be written to (ETXTBSY), and the most + // ordinary reason for one to be running is a dev server started + // with this very launcher. Overwriting in place made `hkm upgrade` + // fail for the entire time `hkm run` was up. rename() replaces the + // directory entry instead of the file: the running process keeps + // its old inode and finishes normally, and the next invocation + // picks up the new build. + const staged = std.fmt.allocPrint(allocator, "{s}.hkm-new", .{to}) catch continue; + copyOne(allocator, io, env, from, staged, false) catch { + copied = false; + }; + if (copied) { + util.chmodExec(io, staged); + Dir.cwd().rename(staged, Dir.cwd(), to, io) catch { + Dir.cwd().deleteFile(io, staged) catch {}; + copied = false; + }; + } } + + if (!copied) { + // Reported, never swallowed. This used to `catch continue` and then + // print "native launcher updated" regardless, so an upgrade that + // installed NOTHING looked identical to one that worked — and the + // next command silently ran the old binary. The usual cause is the + // launcher being executed right now (ETXTBSY): a background `hkm` + // still running holds it busy and every write to it fails. + failed += 1; + prompt.warn(std.fmt.allocPrint( + allocator, + "could not replace {s} — it is still the OLD build.", + .{to}, + ) catch "could not replace the launcher — it is still the OLD build."); + prompt.muted(" check what still holds it: pgrep -af hkm"); + continue; + } + + // The copy above writes bytes only, so the executable bit is lost. A + // launcher installed without it fails at the first invocation with + // "permission denied", long after the install reported success. + util.chmodExec(io, to); + installed_any = true; } - prompt.ok("native launcher updated"); + + if (failed > 0) return; + if (installed_any) prompt.ok("native launcher updated"); } diff --git a/tools/src/lib/banner.zig b/tools/src/lib/banner.zig index d9aefa0..342a460 100644 --- a/tools/src/lib/banner.zig +++ b/tools/src/lib/banner.zig @@ -29,10 +29,43 @@ pub fn repo() []const u8 { /// Full banner: ASCII art + version + tagline. Used as the header of the /// version/update commands. -pub fn print() void { +/// +/// Takes io/env so the PHP line can report the runtime ACTUALLY on this machine +/// rather than a compiled-in constant — which is the only version of the two +/// that can differ from what the user expects, and the one that explains most +/// "it works on my machine" reports. +pub fn print(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map) void { std.debug.print("\n{s}{s}{s}\n", .{ cyan, art, reset }); std.debug.print(" {s}HKM Kernel{s} {s}· Gated Demand Architecture{s}\n", .{ bold, reset, dim, reset }); - std.debug.print(" {s}version {s}{s}{s}\n\n", .{ dim, reset, build_info.version, reset }); + std.debug.print(" {s}version {s}{s}{s}\n", .{ dim, reset, build_info.version, reset }); + std.debug.print(" {s}PHP {s}{s}{s}\n\n", .{ dim, reset, phpVersion(allocator, io, env) orelse "not found", reset }); +} + +/// The PHP runtime's version, or null when php is absent or unreadable. +/// +/// Asked of PHP itself (`-r 'echo PHP_VERSION;'`) rather than parsed out of +/// `php -v`, whose first line carries build metadata and varies between +/// distributions. Honours HKM_PHP_BIN, so the banner reports the interpreter +/// this tool would actually run — not whichever `php` happens to be first on +/// PATH. +/// +/// Never fails the banner: a missing PHP is a real state worth SHOWING (the +/// kernel cannot run without it), not a reason to refuse to print a version. +fn phpVersion(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map) ?[]const u8 { + const bin = env.get("HKM_PHP_BIN") orelse "php"; + + const res = std.process.run(allocator, io, .{ + .argv = &.{ bin, "-r", "echo PHP_VERSION;" }, + .environ_map = env, + }) catch return null; + + switch (res.term) { + .exited => |c| if (c != 0) return null, + else => return null, + } + + const v = std.mem.trim(u8, res.stdout, " \t\r\n"); + return if (v.len == 0) null else v; } /// One-line version, for `hkm --version` piped/scripted use. diff --git a/tools/src/lib/plugin_bootstrap.zig b/tools/src/lib/plugin_bootstrap.zig index f9ba526..b71cb58 100644 --- a/tools/src/lib/plugin_bootstrap.zig +++ b/tools/src/lib/plugin_bootstrap.zig @@ -117,12 +117,44 @@ pub fn collectFromArray( search = pos + needle.len; if (token.len == 0) continue; + // A commented-out provider is not enabled. + // + // Scanning for `::class` with no notion of comments meant the template's + // documented-as-optional identity stack — User, Feedback, Auth, Tenancy, + // all four written as `// \Plugins\User\Provider::class,` — counted as + // enabled. Every new project downloaded and wired four plugins it had + // explicitly not asked for, and the comment that said "enable together + // in an app that needs accounts" was decoration. + if (isCommentedOut(block, b)) continue; + const name = resolvePlugin(token, aliases) orelse token; if (isEnabled(out.items, name)) continue; // de-dupe try out.append(allocator, .{ .name = name, .token = token, .activation = activation }); } } +/// Is the token at `at` inside a comment? +/// +/// Line-level: a `//` earlier on the same line, or a line whose first non-space +/// character starts a block comment or continues one (` * `). That covers how +/// providers are actually commented out in a bootstrap, and — unlike a full PHP +/// parse — cannot itself go wrong in a way that silently drops a live entry. +/// +/// Deliberately NOT fooled by a trailing comment: `Provider::class, // note` +/// has its `//` AFTER the token and stays enabled. +fn isCommentedOut(block: []const u8, at: usize) bool { + const line_start = if (std.mem.lastIndexOfScalar(u8, block[0..at], '\n')) |nl| nl + 1 else 0; + const before = block[line_start..at]; + + if (std.mem.indexOf(u8, before, "//") != null) return true; + + const trimmed = std.mem.trimStart(u8, before, " \t"); + if (std.mem.startsWith(u8, trimmed, "*")) return true; // inside a docblock + if (std.mem.startsWith(u8, trimmed, "/*")) return true; + + return false; +} + pub fn isEnabled(items: []const Enabled, name: []const u8) bool { for (items) |e| { if (std.mem.eql(u8, e.name, name)) return true; @@ -214,15 +246,38 @@ pub fn supportTag(allocator: std.mem.Allocator, folder: []const u8) ![]const u8 return std.fmt.allocPrint(allocator, "{s}{s}]", .{ support_tag_open, folder }); } +/// Is `folder`'s Support helpers require ACTUALLY present? +/// +/// The marker comment alone is not enough. It tracks ownership, not presence, +/// and the two come apart the moment someone deletes the require line while +/// debugging and leaves the comment above it — after which every check keyed on +/// the marker reports the helpers as wired while PHP fatals on the first call +/// to one of them. Requiring the `require_once` itself makes the check say what +/// it claims to say. +pub fn supportRequireWired(allocator: std.mem.Allocator, source: []const u8, folder: []const u8, expr: []const u8) bool { + const tag = supportTag(allocator, folder) catch return false; + if (std.mem.indexOf(u8, source, tag) == null) return false; + + // The expression identifies the file; a require_once naming it is the wiring. + if (expr.len > 0) { + if (std.mem.indexOf(u8, source, expr)) |at| { + const before = source[0..at]; + if (std.mem.lastIndexOf(u8, before, "require_once") != null) return true; + } + return false; + } + return true; +} + /// Insert a managed `require_once ` for `folder`'s Support/helpers.php after /// the autoload call in the bootstrap. `expr` is the PHP expression that follows /// `require_once ` (including its trailing `;`). Idempotent — returns the source /// unchanged (same slice) when the plugin's require is already present, so callers /// can compare pointers to detect a no-op. pub fn insertSupportRequire(allocator: std.mem.Allocator, source: []const u8, folder: []const u8, expr: []const u8) ![]const u8 { - const tag = try supportTag(allocator, folder); - if (std.mem.indexOf(u8, source, tag) != null) return source; // already wired + if (supportRequireWired(allocator, source, folder, expr)) return source; + const tag = try supportTag(allocator, folder); const block = try std.fmt.allocPrint( allocator, "\n// {s} Support helpers — managed by `hkm plugins`\nrequire_once {s}", @@ -339,3 +394,31 @@ pub fn removeFromArray(allocator: std.mem.Allocator, source: []const u8, token: } return .{ .text = try out.toOwnedSlice(allocator), .removed = try removed.toOwnedSlice(allocator) }; } + +test "a commented-out provider is not enabled" { + const a = std.testing.allocator; + const src = + \\withModules([ + \\ \Plugins\Logger\Provider::class, + \\ // Identity stack (enable together in an app that needs accounts): + \\ // \Plugins\User\Provider::class, + \\ // \Plugins\Auth\Provider::class, + \\ \Plugins\View\Provider::class, // still enabled — the // is AFTER it + \\ ]) + \\ ->build(); + ; + + var aliases: std.ArrayList(Alias) = .empty; + defer aliases.deinit(a); + var out: std.ArrayList(Enabled) = .empty; + defer out.deinit(a); + try collectEnabled(a, src, aliases.items, &out); + + try std.testing.expectEqual(@as(usize, 2), out.items.len); + try std.testing.expect(isEnabled(out.items, "Logger")); + try std.testing.expect(isEnabled(out.items, "View")); + try std.testing.expect(!isEnabled(out.items, "User")); + try std.testing.expect(!isEnabled(out.items, "Auth")); +} diff --git a/tools/src/lib/plugin_deps.zig b/tools/src/lib/plugin_deps.zig index ec3e752..a5279ac 100644 --- a/tools/src/lib/plugin_deps.zig +++ b/tools/src/lib/plugin_deps.zig @@ -28,7 +28,7 @@ const Enabled = boot.Enabled; pub const Provider = struct { located: Located, solves: ?[]const u8 = null, - requires: []const []const u8 = &.{}, + requires: []const sources.Requirement = &.{}, pub fn name(self: Provider) []const u8 { return self.located.name; @@ -56,10 +56,33 @@ pub fn catalogue( if (findByName(out.items, p) != null) continue; // first source wins (project shadows kernel) const meta = try sources.readModuleMeta(allocator, io, dir, p); + + // Module-level and route-level requires are MERGED here. + // + // The kernel distinguishes them at runtime — a route-level domain + // is seeded into that one request's graph — but not at boot: a + // route naming a domain no registered module solves fails the whole + // build. So for installing and enabling they are one list. Reading + // only the module-level one is why a project with Tenancy booted + // straight into "Route [GET /tenants] requires unknown module + // domain [http.pageflow]": the walk fetched Tenancy's two declared + // dependencies and none of the four its routes need. + var reqs: std.ArrayList(sources.Requirement) = .empty; + if (meta) |m| { + for (m.requires) |r| try reqs.append(allocator, r); + for (m.route_requires) |r| { + var seen = false; + for (reqs.items) |e| { + if (std.mem.eql(u8, e.domain, r.domain)) seen = true; + } + if (!seen) try reqs.append(allocator, r); + } + } + try out.append(allocator, .{ .located = .{ .name = p, .source = src, .dir = dir }, .solves = if (meta) |m| m.solves else null, - .requires = if (meta) |m| m.requires else &.{}, + .requires = reqs.items, }); } } @@ -106,7 +129,8 @@ fn visit( missing: *std.ArrayList([]const u8), rootFolder: []const u8, ) !void { - for (p.requires) |domain| { + for (p.requires) |req| { + const domain = req.domain; if (providerForDomain(cat, domain)) |dep| { // Don't list the plugin being enabled, and de-dupe. if (util.eqlIgnoreCase(dep.located.name, rootFolder)) continue; @@ -144,8 +168,8 @@ pub fn enabledDependentsOf( /// Does `p` require `domain` directly or transitively (following providers)? fn dependsOnDomain(cat: []const Provider, p: Provider, domain: []const u8, skip: []const u8) bool { for (p.requires) |req| { - if (std.mem.eql(u8, req, domain)) return true; - const next = providerForDomain(cat, req) orelse continue; + if (std.mem.eql(u8, req.domain, domain)) return true; + const next = providerForDomain(cat, req.domain) orelse continue; if (util.eqlIgnoreCase(next.located.name, skip)) continue; if (dependsOnDomain(cat, next, domain, skip)) return true; } diff --git a/tools/src/lib/plugin_domains.zig b/tools/src/lib/plugin_domains.zig new file mode 100644 index 0000000..b79b70e --- /dev/null +++ b/tools/src/lib/plugin_domains.zig @@ -0,0 +1,266 @@ +//! Which plugin provides a given domain. +//! +//! A plugin declares its dependencies in module.json as the DOMAINS it needs +//! ("crypto.services", "cache.redis") — never as repository names. That is the +//! right thing for the framework: a module depends on a capability, not on who +//! happens to ship it. It leaves the installer with a lookup to do, and the +//! lookup cannot be guessed: +//! +//! crypto.services → hkm-plugin-crypto the first segment works +//! logging.application → hkm-plugin-logger …and here it does not +//! http.client → hkm-plugin-http-client +//! http.cookies → hkm-plugin-cookie four different plugins +//! http.pageflow → hkm-plugin-pageflow share one first segment +//! http.security_filters → hkm-plugin-security-filters +//! +//! Thirteen of the twenty-eight first-party domains do not match their +//! repository name, and "http" alone is ambiguous four ways — so a naming +//! convention cannot carry this. It is resolved from three sources, most +//! trustworthy first. + +const std = @import("std"); +const sources = @import("plugin_sources.zig"); +const deps = @import("plugin_deps.zig"); +const util = @import("util.zig"); +const pregistry = @import("plugin_registry.zig"); + +const Io = std.Io; +const EnvMap = std.process.Environ.Map; + +pub const Mapping = struct { domain: []const u8, folder: []const u8 }; + +/// How a domain was resolved — worth reporting, because the three sources carry +/// different weight and a seeded guess can be stale in a way disk never is. +pub const Origin = enum { + /// Read from an installed plugin's own module.json. Cannot be wrong. + installed, + /// From the built-in table below. Right for first-party plugins, and only + /// as current as the release of this tool. + seed, + /// Declared by the plugin that needs it, as a repo on its requires[] entry. + /// The only source that can reach a plugin nothing else has heard of. + declared, +}; + +pub const Resolution = struct { + folder: []const u8, + origin: Origin, + /// Set only for `.declared` — where to fetch it, and at which ref. + repo: []const u8 = "", + version: []const u8 = "", +}; + +/// First-party domain → plugin folder. +/// +/// Generated from the plugin repositories rather than typed, and ordered by +/// domain. It exists for one case: resolving a dependency of a plugin that is +/// not installed yet, where there is no module.json on disk to read. Once a +/// plugin IS installed its own manifest takes over, which is why a stale entry +/// here degrades to a wrong first guess rather than a wrong answer. +/// +/// Adding a first-party plugin means adding its line. `hkm plugins domains` +/// prints the table, and the test at the bottom of this file keeps it honest. +pub const seed = [_]Mapping{ + .{ .domain = "audit.trail", .folder = "Audit" }, + .{ .domain = "auth.identity", .folder = "Auth" }, + .{ .domain = "auth.social", .folder = "SocialAuth" }, + .{ .domain = "authorization.policy", .folder = "Authorization" }, + .{ .domain = "cache.redis", .folder = "RedisCache" }, + .{ .domain = "crypto.services", .folder = "Crypto" }, + .{ .domain = "database.management", .folder = "Database" }, + .{ .domain = "dev.tooling", .folder = "DevTools" }, + .{ .domain = "edge.routing", .folder = "Edge" }, + .{ .domain = "feedback.management", .folder = "Feedback" }, + .{ .domain = "http.client", .folder = "HttpClient" }, + .{ .domain = "http.cookies", .folder = "Cookie" }, + .{ .domain = "http.pageflow", .folder = "Pageflow" }, + .{ .domain = "http.security_filters", .folder = "SecurityFilters" }, + .{ .domain = "i18n.translation", .folder = "I18n" }, + .{ .domain = "logging.application", .folder = "Logger" }, + .{ .domain = "mail.delivery", .folder = "Mail" }, + .{ .domain = "oauth.server", .folder = "OAuth2" }, + .{ .domain = "seo.management", .folder = "SiteSEO" }, + .{ .domain = "session.management", .folder = "Session" }, + .{ .domain = "storage.local", .folder = "Storage" }, + .{ .domain = "system.commands", .folder = "Commands" }, + .{ .domain = "tenancy.routing", .folder = "Tenancy" }, + .{ .domain = "tenant.settings", .folder = "Settings" }, + .{ .domain = "user.management", .folder = "User" }, + .{ .domain = "validation.rules", .folder = "Validation" }, + .{ .domain = "view.rendering", .folder = "View" }, + .{ .domain = "vite.manifest", .folder = "ViteManifest" }, +}; + +/// The plugin folder that provides `domain`, or null when nothing knows. +/// +/// Consults installed plugins first: their module.json is the authority, it +/// covers third-party plugins the table has never heard of, and it is right +/// even when the table is out of date. +pub fn resolve( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + domain: []const u8, +) !?Resolution { + const srcs = try sources.discoverSources(allocator, io, env, projectRoot); + var cat: std.ArrayList(deps.Provider) = .empty; + try deps.catalogue(allocator, io, srcs, &.{ .project, .kernel }, &cat); + + if (deps.providerForDomain(cat.items, domain)) |p| { + return .{ .folder = p.located.name, .origin = .installed }; + } + + for (seed) |m| { + if (std.mem.eql(u8, m.domain, domain)) return .{ .folder = m.folder, .origin = .seed }; + } + + return null; +} + +/// As `resolve`, but against a catalogue the caller already built — for loops +/// that would otherwise re-scan every plugins directory once per domain. +pub fn resolveIn(cat: []const deps.Provider, domain: []const u8) ?Resolution { + if (deps.providerForDomain(cat, domain)) |p| { + return .{ .folder = p.located.name, .origin = .installed }; + } + for (seed) |m| { + if (std.mem.eql(u8, m.domain, domain)) return .{ .folder = m.folder, .origin = .seed }; + } + return null; +} + +/// Resolve a requirement, allowing the repo it declares to answer for domains +/// nothing else knows. +/// +/// The declared repo is consulted LAST, after disk and the built-in table, and +/// that ordering is a security property rather than a preference. A plugin can +/// name any URL it likes; if a declaration outranked the curated table, then +/// installing any plugin could silently redirect `crypto.services` — a +/// first-party domain, on the trusted path, in the shared kernel directory — to +/// a repository of its author's choosing. Consulting it last means a +/// declaration can only ever REACH a domain the platform has no answer for, +/// which is the case it exists to serve. +/// +/// `overridden` is set when a declaration was ignored because the platform +/// already had an answer, so the caller can say so rather than diverge silently. +pub fn resolveRequirement( + cat: []const deps.Provider, + req: sources.Requirement, + overridden: *bool, +) ?Resolution { + overridden.* = false; + + if (resolveIn(cat, req.domain)) |hit| { + if (req.repo.len > 0 and hit.origin == .seed) overridden.* = true; + return hit; + } + + if (req.repo.len == 0) return null; + + // Nothing else knows this domain. The folder name comes from the repo, and + // is corrected from the plugin's own module.json once it is fetched. + const folder = pregistry.nameFromRemote(std.heap.page_allocator, req.repo) catch return null; + return .{ + .folder = folder, + .origin = .declared, + .repo = req.repo, + .version = req.version, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "the seed table has no duplicate or empty entries" { + // A duplicated domain would resolve to whichever line came first, silently. + for (seed, 0..) |a, i| { + try std.testing.expect(a.domain.len > 0); + try std.testing.expect(a.folder.len > 0); + for (seed[i + 1 ..]) |b| { + try std.testing.expect(!std.mem.eql(u8, a.domain, b.domain)); + } + } +} + +test "every seeded folder is the canonical spelling of itself" { + // The folder has to match the PSR-4 namespace exactly, so a seed entry that + // is not already canonical would install to a directory the autoloader + // never looks in — the failure this whole lookup exists to prevent. + const a = std.testing.allocator; + for (seed) |m| { + const canon = try pregistry.canonicalName(a, m.folder); + defer a.free(canon); + try std.testing.expectEqualStrings(m.folder, canon); + } +} + +test "the domains a convention could not reach are the ones that matter" { + // Guards the premise of this file: if these ever became derivable from + // their domain, the table would be dead weight. They are not. + const cases = [_]Mapping{ + .{ .domain = "logging.application", .folder = "Logger" }, + .{ .domain = "cache.redis", .folder = "RedisCache" }, + .{ .domain = "http.cookies", .folder = "Cookie" }, + .{ .domain = "http.security_filters", .folder = "SecurityFilters" }, + .{ .domain = "oauth.server", .folder = "OAuth2" }, + .{ .domain = "seo.management", .folder = "SiteSEO" }, + }; + for (cases) |c| { + const got = resolveIn(&.{}, c.domain) orelse return error.Unresolved; + try std.testing.expectEqualStrings(c.folder, got.folder); + try std.testing.expect(got.origin == .seed); + } +} + +test "a declared repo reaches an unknown domain but never overrides a known one" { + const sources_mod = @import("plugin_sources.zig"); + var overridden = false; + + // The case it exists for: nothing on disk, nothing in the table. + const unknown = sources_mod.Requirement{ + .domain = "telemetry.exotic", + .repo = "https://github.com/acme/hkm-plugin-telemetry.git", + .version = "^1.2", + }; + const hit = resolveRequirement(&.{}, unknown, &overridden) orelse return error.Unresolved; + try std.testing.expect(hit.origin == .declared); + try std.testing.expectEqualStrings("Telemetry", hit.folder); + try std.testing.expectEqualStrings("^1.2", hit.version); + try std.testing.expect(!overridden); + + // The case that must NOT work: a plugin cannot redirect a platform domain + // to a repository of its choosing — that domain installs into the SHARED + // kernel directory, where it would affect every project on the machine. + const hijack = sources_mod.Requirement{ + .domain = "crypto.services", + .repo = "https://github.com/attacker/hkm-plugin-crypto.git", + }; + const safe = resolveRequirement(&.{}, hijack, &overridden) orelse return error.Unresolved; + try std.testing.expect(safe.origin == .seed); + try std.testing.expectEqualStrings("Crypto", safe.folder); + try std.testing.expectEqualStrings("", safe.repo); + // …and the caller is told, so the divergence is never silent. + try std.testing.expect(overridden); + + // No repo and no answer: unresolved, not guessed. + const bare = sources_mod.Requirement{ .domain = "nothing.knows.this" }; + try std.testing.expect(resolveRequirement(&.{}, bare, &overridden) == null); +} + +test "a plugin's route-level requires are dependencies too" { + // Regression: Tenancy declares database.management + i18n.translation at + // module level, and http.pageflow / auth.identity / user.management / + // audit.trail on individual ROUTES. Reading only the module level installed + // two of six, and the project failed to boot on + // Route [GET /tenants] requires unknown module domain [http.pageflow] + // because CompileRouteManifestStage enforces route requires at BUILD time. + const sources_mod = @import("plugin_sources.zig"); + + const route_only = [_]sources_mod.Requirement{.{ .domain = "http.pageflow" }}; + var overridden = false; + + const hit = resolveRequirement(&.{}, route_only[0], &overridden) orelse return error.Unresolved; + try std.testing.expectEqualStrings("Pageflow", hit.folder); +} diff --git a/tools/src/lib/plugin_git.zig b/tools/src/lib/plugin_git.zig index 95b2b0e..8b46765 100644 --- a/tools/src/lib/plugin_git.zig +++ b/tools/src/lib/plugin_git.zig @@ -142,6 +142,83 @@ pub fn resolveVersion( return null; } +/// A ref a plugin can be installed at. +pub const Ref = struct { + name: []const u8, + kind: enum { tag, branch }, + /// Null for a branch — a branch has no version, which is exactly why one + /// cannot be pinned in the shared version-keyed store. + version: ?semver.Version = null, + + pub fn isBranch(self: Ref) bool { + return self.kind == .branch; + } +}; + +/// Every branch head on the remote. +pub fn listBranches(allocator: std.mem.Allocator, io: Io, env: *EnvMap, url: []const u8) GitError![]const []const u8 { + const listing = capture(allocator, io, env, &.{ "git", "ls-remote", "--heads", "--refs", url }) orelse + return GitError.RemoteUnreachable; + + var out: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, listing, '\n'); + while (lines.next()) |line| { + const marker = "refs/heads/"; + const idx = std.mem.indexOf(u8, line, marker) orelse continue; + const name = std.mem.trim(u8, line[idx + marker.len ..], " \t\r"); + if (name.len == 0) continue; + out.append(allocator, allocator.dupe(u8, name) catch continue) catch continue; + } + return out.items; +} + +/// Resolve what the user asked for into a concrete ref on the remote. +/// +/// `want` may be a semver constraint ("^1.2"), an exact tag ("v1.2.0"), or a +/// branch ("main", "develop"). They are tried in that order, and the order is +/// the point: a bare "1.0" is a CONSTRAINT, not a branch named 1.0, and +/// resolving it as a branch would quietly install something else entirely. +/// +/// Empty `want` means the newest release tag — never a branch. Falling back to +/// a branch when a plugin has no releases would install an unpinnable moving +/// target from a bare `hkm plugins install `. +pub fn resolveRef( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + url: []const u8, + want: []const u8, +) GitError!?Ref { + const w = std.mem.trim(u8, want, " \t\r\n"); + const tags = try listTags(allocator, io, env, url); + + if (w.len == 0) { + if (tags.len == 0) return null; + return .{ .name = tags[0].name, .kind = .tag, .version = tags[0].version }; + } + + // Exact tag first: an exact name is unambiguous, and checking it before the + // constraint means a tag whose name is not semver-shaped still installs. + for (tags) |t| { + if (std.mem.eql(u8, t.name, w)) return .{ .name = t.name, .kind = .tag, .version = t.version }; + } + + // Then as a semver constraint. + for (tags) |t| { + const ok = semver.satisfies(t.version, w) catch break; // not a constraint — try a branch + if (ok) return .{ .name = t.name, .kind = .tag, .version = t.version }; + } + + // Finally a branch. Only reached when it is neither a known tag nor a + // constraint any tag satisfies. + const branches = listBranches(allocator, io, env, url) catch &[_][]const u8{}; + for (branches) |b| { + if (std.mem.eql(u8, b, w)) return .{ .name = b, .kind = .branch }; + } + + return null; +} + /// Clone `url` into `dest` at `ref`. /// /// Shallow (`--depth 1`) and single-branch: a plugin is consumed, not developed, diff --git a/tools/src/lib/plugin_install.zig b/tools/src/lib/plugin_install.zig index 38d4790..68090d5 100644 --- a/tools/src/lib/plugin_install.zig +++ b/tools/src/lib/plugin_install.zig @@ -23,6 +23,9 @@ const semver = @import("semver.zig"); const banner = @import("banner.zig"); const prompt = @import("prompt.zig"); const util = @import("util.zig"); +const store = @import("plugin_store.zig"); +const run_cmd = @import("../commands/run.zig"); +const kernel = @import("kernel.zig"); const Dir = std.Io.Dir; const Io = std.Io; @@ -32,6 +35,10 @@ pub const Outcome = union(enum) { installed: lockfile.Entry, /// Already present at the requested version — nothing to do. up_to_date: lockfile.Entry, + /// The version was already in the shared store, so nothing was downloaded, + /// but this PROJECT gained it. Distinct from `up_to_date`, which reads as + /// "nothing happened" — and something did. + linked: lockfile.Entry, updated: struct { from: []const u8, to: lockfile.Entry }, /// Refused. `why` is a complete, user-facing sentence. refused: []const u8, @@ -46,11 +53,153 @@ pub const Options = struct { force: bool = false, /// Clone full history instead of --depth 1. full: bool = false, + /// Run the plugin's own test suite before installing it. + verify: bool = true, + /// Whether a failing suite may be escalated to the user. FALSE for + /// unattended callers (`hkm new`, CI): there is nobody to answer, and + /// installing a plugin whose tests fail because nothing could ask is how a + /// broken plugin reaches a project silently. + interactive: bool = true, + /// Explicit git remote, bypassing name→URL resolution. Set when the user + /// gave a URL instead of a plugin name, and when restoring a lock entry + /// that records where its plugin actually came from. + remote: []const u8 = "", + /// Skip the composer autoload refresh. For callers installing SEVERAL + /// plugins: the dump is a full classmap rebuild of the whole tree, so doing + /// it per plugin costs N rebuilds to reach the state one at the end gives. + /// A caller that sets this MUST call refreshAutoload itself when done, or + /// the plugins are on disk and invisible to PHP. + defer_autoload: bool = false, }; -/// Directory a plugin would be installed into for this project. -pub fn targetDir(allocator: std.mem.Allocator, projectRoot: []const u8, name: []const u8) ![]const u8 { - return std.fs.path.join(allocator, &.{ projectRoot, "plugins", name }); +/// The version-keyed store — always `/plugin-store//`. +/// +/// One physical copy per (plugin, version), reused by every project that pins +/// that version — so two projects on the same release share the download, and a +/// project on an older release keeps its own copy rather than being dragged +/// forward. A project references its pin with a symlink from its own plugins/, +/// which is what lets the PROJECT's composer resolve it and what makes the +/// pinned version per-project rather than machine-wide. +/// +/// Deliberately NOT under `/plugins/`: that path is PSR-4 mapped by the +/// kernel's composer, and a `.store` directory inside it would be scanned as if +/// every version were a plugin. +pub fn storeDir( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + name: []const u8, + version: []const u8, +) !?[]const u8 { + return storeDirFor(allocator, io, env, projectRoot, name, version, ""); +} + +/// As `storeDir`, with the first-party decision already made — see `pluginsRootFor`. +pub fn storeDirFor( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + name: []const u8, + version: []const u8, + remote: []const u8, +) !?[]const u8 { + // ALWAYS the global cache, first-party or not. + // + // It used to follow the install target, so a third-party plugin was stored + // under the PROJECT — and every other project on the machine wanting the + // same plugin at the same version downloaded and kept its own copy. That + // defeats the point: one copy per (plugin, version, origin), shared. + // + // Storing centrally does NOT change which composer owns the plugin. What + // composer sees is the LINK in /plugins/, and that is still + // per project — the store is only where the bytes live. + const fallback = try kernelFallbackRoot(allocator, io, env, projectRoot); + const path = try store.entryDir(allocator, env, fallback, name, version, remote); + return path; +} + +/// The kernel root, used only as the store location of last resort — a machine +/// with no HOME and no configured cache directory. +fn kernelFallbackRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap, projectRoot: []const u8) ![]const u8 { + const plugins = try pluginsRootFor(allocator, io, env, projectRoot, true); + return util.parentOf(plugins) orelse projectRoot; +} + +/// Where first-party plugins are installed: the KERNEL's plugins directory. +/// +/// Not the project's. Plugins under the AlfaCode-Team org are shared +/// infrastructure — one copy serves every project on the machine, which is what +/// plugin_sources already calls the "kernel" source and treats as the +/// contributor-protected one. Installing per project would give each its own +/// copy of the same nineteen packages and no single place to update them. +/// +/// Falls back to the project's own plugins/ when no kernel root can be resolved +/// (a bare checkout, or a project outside a kernel install), so the command +/// still works rather than failing on a machine that has no /opt install. +pub fn targetDir( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + name: []const u8, +) ![]const u8 { + const dir = try pluginsRoot(allocator, io, env, projectRoot); + return std.fs.path.join(allocator, &.{ dir, name }); +} + +/// The directory plugins are installed into, created if absent. +pub fn pluginsRoot( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, +) ![]const u8 { + return pluginsRootFor(allocator, io, env, projectRoot, pregistry.isFirstParty(env)); +} + +/// As `pluginsRoot`, but with the first-party decision already made. +/// +/// Separate because an explicit remote answers that question by itself, and the +/// environment-based answer would be wrong for it: `hkm plugins install +/// https://github.com/AlfaCode-Team/hkm-plugin-logger.git` must reach the same +/// shared kernel directory as `hkm plugins install logger`, and a URL pointing +/// anywhere else must not. +pub fn pluginsRootFor( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + projectRoot: []const u8, + first_party: bool, +) ![]const u8 { + // Third-party plugins belong to the project that asked for them, and are + // resolved by the PROJECT's composer. Only first-party packages go into the + // shared kernel. + if (!first_party) { + return std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); + } + + if (try sources.kernelPluginsDir(allocator, io, env, projectRoot)) |kd| return kd; + + // kernelPluginsDir only returns a path that already EXISTS. A fresh kernel + // install has no plugins/ yet, so derive it from the kernel root and let the + // caller create it. + if (env.get("HKM_KERNEL_HOME")) |h| { + if (h.len > 0) return std.fmt.allocPrint(allocator, "{s}/plugins", .{util.trimSlash(h)}); + } + if (try kernelRootFromCli(allocator, io, env)) |root| { + return std.fmt.allocPrint(allocator, "{s}/plugins", .{root}); + } + + return std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); +} + +/// Kernel root derived from the resolved CLI path (`/bin/hkm`). +fn kernelRootFromCli(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const u8 { + const r = kernel.resolve(allocator, io, env) catch return null; + const bin = std.fs.path.dirname(r.path) orelse return null; + return std.fs.path.dirname(bin); } /// Read the kernel constraint out of an already-fetched plugin directory. @@ -84,6 +233,92 @@ fn gateMessage(allocator: std.mem.Allocator, env: *EnvMap, name: []const u8, con }; } + +/// Outcome of running a freshly fetched plugin's own test suite. +const Verdict = enum { + passed, + failed, + /// No test suite, or no runner after a successful dependency install. + unavailable, + /// Dependencies could not be resolved, so the suite could not be reached. + /// Distinct from `unavailable` because the causes and the fix differ: this + /// one is almost always the environment (an unwritable composer cache, no + /// network, missing auth), not the plugin. + blocked, +}; + +/// Install the plugin's dev dependencies and run its tests, in `dir`. +/// +/// A packaged kernel ships no phpunit — install.sh runs `composer install +/// --no-dev` — so the runner has to come from the plugin itself. That costs a +/// composer install per plugin, which is why `verify` is a switch rather than +/// unconditional. +fn runPluginTests(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dir: []const u8, name: []const u8) Verdict { + const tests_dir = std.fs.path.join(allocator, &.{ dir, "tests" }) catch return .unavailable; + if (!util.dirExists(Dir.cwd(), io, tests_dir)) return .unavailable; // nothing to run + + prompt.muted(std.fmt.allocPrint(allocator, "{s}: resolving test dependencies…", .{name}) catch name); + + var composer = [_][]const u8{ "composer", "install", "--no-interaction", "--no-progress", "--working-dir", dir }; + const cinstall = run_cmd.spawnWait(io, env, &composer) catch return .unavailable; + if (cinstall != 0) return .blocked; + + const phpunit = std.fs.path.join(allocator, &.{ dir, "vendor", "bin", "phpunit" }) catch return .unavailable; + if (!util.fileExists(io, phpunit)) return .unavailable; + + prompt.muted(std.fmt.allocPrint(allocator, "{s}: running tests…", .{name}) catch name); + + // Paths are passed EXPLICITLY rather than relying on the working directory: + // hkm runs from wherever the user invoked it, so phpunit found neither a + // phpunit.xml nor a test path and simply printed its own usage — which the + // exit code then reported as a failure. + var run = [_][]const u8{ phpunit, "--no-coverage", "--do-not-cache-result", "--bootstrap", "", tests_dir }; + const autoload = std.fs.path.join(allocator, &.{ dir, "vendor", "autoload.php" }) catch return .unavailable; + run[4] = autoload; + const code = run_cmd.spawnWait(io, env, &run) catch return .unavailable; + return if (code == 0) .passed else .failed; +} + +/// Strip everything a consumer does not need from an installed plugin. +/// +/// tests/ and vendor/ are development artefacts: vendor/ here holds the plugin's +/// DEV dependencies (phpunit and friends) pulled purely to run the suite, and +/// leaving it would shadow the kernel's own autoloader with a second copy of +/// shared packages. Removed after verification, never before — deleting the +/// tests first would make the verification impossible. +fn stripDevArtefacts(io: Io, allocator: std.mem.Allocator, dir: []const u8) void { + for ([_][]const u8{ "tests", "vendor", "composer.lock", "phpunit.xml", "phpunit.xml.dist" }) |entry| { + const path = std.fs.path.join(allocator, &.{ dir, entry }) catch continue; + Dir.cwd().deleteTree(io, path) catch {}; + } +} + +/// Make the kernel's autoloader aware of a newly installed plugin. +/// +/// The kernel maps `Plugins\` to its plugins/ directory, so a new folder is only +/// discoverable once the classmap is regenerated. +pub fn refreshAutoload(allocator: std.mem.Allocator, io: Io, env: *EnvMap, pluginsDir: []const u8) void { + const kernel_root = util.parentOf(pluginsDir) orelse return; + const composer_json = std.fs.path.join(allocator, &.{ kernel_root, "composer.json" }) catch return; + if (!util.fileExists(io, composer_json)) return; + + var argv = [_][]const u8{ "composer", "dump-autoload", "--no-interaction", "--working-dir", kernel_root }; + _ = run_cmd.spawnWait(io, env, &argv) catch {}; +} + +/// Refresh every composer that could own a plugin directory for this project. +/// +/// The counterpart to `Options.defer_autoload`: call once after a batch. +pub fn refreshAllAutoload(allocator: std.mem.Allocator, io: Io, env: *EnvMap, projectRoot: []const u8) void { + const project_plugins = std.fs.path.join(allocator, &.{ projectRoot, "plugins" }) catch return; + refreshAutoload(allocator, io, env, project_plugins); + + const kernel_plugins = pluginsRootFor(allocator, io, env, projectRoot, true) catch return; + if (!std.mem.eql(u8, kernel_plugins, project_plugins)) { + refreshAutoload(allocator, io, env, kernel_plugins); + } +} + /// Install a plugin into `/plugins/` from its git remote. /// /// Idempotent: an already-present plugin at the requested version reports @@ -100,15 +335,40 @@ pub fn install( return .{ .refused = "git is not installed or not on PATH — it is required to fetch plugins." }; } - const remote = try pregistry.remoteFor(allocator, env, name); - const dest = try targetDir(allocator, projectRoot, name); - const pluginsDir = try std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); + // An explicit remote wins over name→URL resolution: it is how a fork, a + // private mirror or a plugin that was never in the registry gets installed, + // and how a lock entry is restored from wherever its plugin came from. + const remote = if (opts.remote.len > 0) + opts.remote + else + try pregistry.remoteFor(allocator, env, name); + + // Where it lands follows the REMOTE, not the environment — see pluginsRootFor. + const first_party = if (opts.remote.len > 0) + pregistry.remoteIsFirstParty(env, opts.remote) + else + pregistry.isFirstParty(env); - const already = git.isRepo(io, dest, allocator); + const pluginsDir = try pluginsRootFor(allocator, io, env, projectRoot, first_party); + + // The DIRECTORY must match the PSR-4 namespace, not whatever the user + // typed: `install crypto` has to produce plugins/Crypto or the autoloader + // will never find Plugins\Crypto\Provider. + var folder = try pregistry.canonicalName(allocator, name); + var dest = try std.fs.path.join(allocator, &.{ pluginsDir, folder }); + + // A REAL working copy at dest — not a link into the shared store. + // + // The distinction decides whether the in-place update path below may run, + // and getting it wrong is destructive: `git checkout` through a store + // symlink rewrites the shared (plugin, version) directory that every other + // project pinning that version is linked to. A managed link is re-pointed, + // never checked out. + const already = !util.isSymlink(io, dest) and git.isRepo(io, dest, allocator); // Resolve the constraint to a concrete tag BEFORE fetching, so we never // install from a moving branch. - const tag = git.resolveVersion(allocator, io, env, remote, opts.version) catch |e| { + const tag = git.resolveRef(allocator, io, env, remote, opts.version) catch |e| { return .{ .refused = try std.fmt.allocPrint( allocator, "{s}: {s} ({s})", @@ -123,7 +383,7 @@ pub fn install( const msg = if (opts.version.len > 0) try std.fmt.allocPrint( allocator, - "{s} has no release matching '{s}' on {s}. Run `hkm plugins versions {s}` to see what exists.", + "{s} has no tag, version or branch matching '{s}' on {s}. Run `hkm plugins versions {s}` to see what exists.", .{ name, opts.version, remote, name }, ) else @@ -135,15 +395,81 @@ pub fn install( return .{ .refused = msg }; }; + // ── Already in the store at this version? ─────────────────────────────── + // + // Two projects pinning the same release must not download it twice. The + // store is keyed by (plugin, version), so a second project just links at + // what is already there. + // A branch is a moving target with no version, and the store's whole + // premise is that a (plugin, version) directory never changes. Two projects + // tracking "main" at different commits would collide on one path, and the + // second would silently get the first's checkout. Branch installs therefore + // use the flat per-project layout instead. + const storable = !want.isBranch(); + + // The hashed entry, or a pre-hash one left by an older layout. + // + // Migrated caches keep their bare `` directory names, and projects + // are symlinked straight at those paths. Looking only for the hashed name + // would miss every one of them — re-downloading the entire cache once, and + // leaving the old copies orphaned but still linked. Accepting the legacy + // name (without renaming it, which would break those links) means the two + // layouts coexist and new installs converge on the hashed one. + const store_hit: ?[]const u8 = if (!storable) null else blk: { + if (try storeDirFor(allocator, io, env, projectRoot, folder, want.name, remote)) |hashed| { + if (util.dirExists(Dir.cwd(), io, hashed)) break :blk hashed; + } + if (try storeDirFor(allocator, io, env, projectRoot, folder, want.name, "")) |legacy| { + if (util.dirExists(Dir.cwd(), io, legacy)) break :blk legacy; + } + break :blk null; + }; + + if (store_hit) |store_path| { + { + const link = try std.fs.path.join(allocator, &.{ projectRoot, "plugins", folder }); + const had_it = util.dirExists(Dir.cwd(), io, link); + + // WHERE the existing link points, not merely that one exists. + // + // Testing only for existence reported "up to date" for a link that + // was about to be repointed at a different version — or, after an + // install from a fork's URL, at a different plugin entirely. The + // relink happened either way; only the message was wrong, which is + // the worst of both. + const current = if (had_it) util.linkTarget(allocator, io, link) else null; + const unchanged = if (current) |c| std.mem.eql(u8, c, store_path) else false; + + if (!opts.dry_run and !unchanged) try linkIntoProject(allocator, io, projectRoot, folder, store_path); + + const entry = lockfile.Entry{ + .name = folder, + .remote = remote, + .version = want.name, + .commit = git.headCommit(allocator, io, env, store_path) orelse "", + .kernel = constraintOf(allocator, io, util.parentOf(store_path) orelse store_path, std.fs.path.basename(store_path)) orelse "", + }; + + if (unchanged) return .{ .up_to_date = entry }; + if (!had_it) return .{ .linked = entry }; + + // Repointed. The version it came FROM is the store_path directory the old + // link named; a real directory (a pre-store_path install) has no version + // in its path, so say so rather than inventing one. + const from = if (current) |c| store.versionOf(std.fs.path.basename(c)) else "an unmanaged copy"; + return .{ .updated = .{ .from = from, .to = entry } }; + } + } + if (already) { const current = git.headTag(allocator, io, env, dest) orelse ""; if (std.mem.eql(u8, current, want.name)) { return .{ .up_to_date = .{ - .name = name, + .name = folder, .remote = remote, .version = want.name, .commit = git.headCommit(allocator, io, env, dest) orelse "", - .kernel = constraintOf(allocator, io, pluginsDir, name) orelse "", + .kernel = constraintOf(allocator, io, pluginsDir, folder) orelse "", } }; } @@ -158,10 +484,10 @@ pub fn install( if (opts.dry_run) { return .{ .updated = .{ .from = current, .to = .{ - .name = name, + .name = folder, .remote = remote, .version = want.name, - .kernel = constraintOf(allocator, io, pluginsDir, name) orelse "", + .kernel = constraintOf(allocator, io, pluginsDir, folder) orelse "", } } }; } @@ -183,7 +509,7 @@ pub fn install( .remote = remote, .version = want.name, .commit = git.headCommit(allocator, io, env, dest) orelse "", - .kernel = constraintOf(allocator, io, pluginsDir, name) orelse "", + .kernel = constraintOf(allocator, io, pluginsDir, folder) orelse "", } } }; } @@ -209,30 +535,203 @@ pub fn install( const staged_name = std.fs.path.basename(staging); const constraint = constraintOf(allocator, io, staged_parent, staged_name); + // The plugin's own module.json outranks the repository name. + // + // Installing by name, the two always agree. Installing by URL they need + // not: a fork called `our-logger`, or a repo that simply spells its name + // differently, would land in plugins/OurLogger while its classes live in + // Plugins\Logger — present on disk, invisible to PSR-4, and surfacing much + // later as "Class does not exist". Correct it here, before anything moves. + if (try sources.readModuleMeta(allocator, io, staged_parent, staged_name)) |meta| { + if (meta.name) |declared_raw| if (declared_raw.len > 0) { + const declared = try pregistry.canonicalName(allocator, declared_raw); + if (!std.mem.eql(u8, declared, folder)) { + prompt.muted(try std.fmt.allocPrint( + allocator, + "{s}: the repository declares itself as '{s}' — installing under that name.", + .{ folder, declared }, + )); + folder = declared; + dest = try std.fs.path.join(allocator, &.{ pluginsDir, folder }); + } + }; + } + if (try gateMessage(allocator, env, name, constraint)) |msg| { Dir.cwd().deleteTree(io, staging) catch {}; return .{ .refused = msg }; } + // ── Verify BEFORE the plugin lands in plugins/ ─────────────────────────── + // + // Run in the staging copy so a plugin whose tests fail never reaches the + // directory the bootstrap wires from. Verifying after the move would mean + // deciding what to do with a broken plugin that is already installed. + if (opts.verify) { + switch (runPluginTests(allocator, io, env, staging, name)) { + .passed => prompt.ok(try std.fmt.allocPrint(allocator, "{s}: tests passed", .{name})), + .unavailable => prompt.muted(try std.fmt.allocPrint( + allocator, + "{s}: no test suite to run", + .{name}, + )), + // Not the plugin's fault, and not something to fail the install + // over — but say WHY, because "could not verify" with no cause + // sends people looking at the plugin. + .blocked => { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{s}: could not resolve test dependencies — installed WITHOUT verification.", + .{name}, + )); + prompt.muted(" usually: an unwritable composer cache, no network, or GitHub auth."); + prompt.muted(" if the cache is root-owned: sudo chown -R \"$USER\" ~/.cache/composer"); + }, + .failed => { + const accepted = opts.interactive and prompt.confirm( + io, + try std.fmt.allocPrint( + allocator, + "{s}: its tests FAILED. Install it anyway?", + .{name}, + ), + false, + ); + if (!accepted) { + Dir.cwd().deleteTree(io, staging) catch {}; + return .{ .refused = try std.fmt.allocPrint( + allocator, + "{s}: test suite failed — not installed.{s}", + .{ + name, + if (opts.interactive) + "" + else + " Nothing could ask, so it was skipped rather than installed unverified; re-run interactively to override.", + }, + ) }; + } + prompt.warn(try std.fmt.allocPrint( + allocator, + "{s}: installing despite failing tests, at your request.", + .{name}, + )); + }, + } + } + + // Only now that it is trusted: drop the development artefacts. + stripDevArtefacts(io, allocator, staging); + + // Land it in the version-keyed store when one is available, so the copy is + // shared; fall back to the flat plugins/ layout when it is not. + const final_dest = if (storable) blk_outer: { + const store_path = (try storeDirFor(allocator, io, env, projectRoot, folder, want.name, remote)) orelse break :blk_outer dest; + if (util.parentOf(store_path)) |parent| Dir.cwd().createDirPath(io, parent) catch {}; + break :blk_outer store_path; + } else dest; + Dir.cwd().createDirPath(io, pluginsDir) catch {}; - Dir.cwd().rename(staging, Dir.cwd(), dest, io) catch { + + // Landing flat, over a path that is currently a link into the store: drop + // the link first. Renaming onto it would either fail or, worse, follow it + // and write through into the shared copy. + if (std.mem.eql(u8, final_dest, dest) and util.isSymlink(io, dest)) { + Dir.cwd().deleteFile(io, dest) catch {}; + } + + Dir.cwd().rename(staging, Dir.cwd(), final_dest, io) catch { Dir.cwd().deleteTree(io, staging) catch {}; return .{ .refused = try std.fmt.allocPrint( allocator, "{s}: fetched successfully but could not be moved into {s}.", - .{ name, dest }, + .{ name, final_dest }, ) }; }; + // Point the project at the version it just pinned. Without this the plugin + // sits in the store and the project cannot see it. + if (!std.mem.eql(u8, final_dest, dest)) { + try linkIntoProject(allocator, io, projectRoot, folder, final_dest); + } + + // The composer that owns the directory needs its classmap regenerated + // before the new plugin is discoverable. + // + // Two directories can be involved — the shared kernel's and the project's — + // but for a third-party plugin they are the SAME path, and dumping it twice + // rebuilt the entire classmap for no gain. Deferred entirely when the caller + // is installing a batch and will dump once at the end. + if (!opts.defer_autoload) { + const project_plugins = try std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); + refreshAutoload(allocator, io, env, pluginsDir); + if (!std.mem.eql(u8, pluginsDir, project_plugins)) { + refreshAutoload(allocator, io, env, project_plugins); + } + } + return .{ .installed = .{ - .name = name, + .name = folder, .remote = remote, .version = want.name, - .commit = git.headCommit(allocator, io, env, dest) orelse "", + .commit = git.headCommit(allocator, io, env, final_dest) orelse "", .kernel = constraint orelse "", } }; } +/// Link `/plugins/` at the store copy the project pinned. +/// +/// A symlink rather than a copy: the point of the store is that one version +/// exists once on disk. The project's own composer maps Plugins\\ to plugins/, +/// so it resolves THROUGH the link — which is what makes the pinned version a +/// per-project fact rather than a machine-wide one. +fn linkIntoProject( + allocator: std.mem.Allocator, + io: Io, + projectRoot: []const u8, + folder: []const u8, + target: []const u8, +) !void { + const plugins = try std.fs.path.join(allocator, &.{ projectRoot, "plugins" }); + Dir.cwd().createDirPath(io, plugins) catch {}; + + const link = try std.fs.path.join(allocator, &.{ plugins, folder }); + + // Create the new link under a temporary name and RENAME it over the old + // one. Deleting first and linking second leaves a window — and, if the + // symlink call fails, a permanent state — where the project has no plugin + // at all, having had a working one a moment earlier. rename(2) replaces + // atomically. + const tmp = try std.fmt.allocPrint(allocator, "{s}.hkm-new", .{link}); + Dir.cwd().deleteFile(io, tmp) catch {}; + Dir.cwd().deleteTree(io, tmp) catch {}; + + Dir.cwd().symLink(io, target, tmp, .{ .is_directory = true }) catch |e| { + prompt.warn(std.fmt.allocPrint( + allocator, + "{s}: could not link into the project ({t}) — the plugin is in the store but this project cannot see it.", + .{ folder, e }, + ) catch folder); + return e; + }; + + // A previous FLAT install leaves a real directory; rename cannot replace a + // non-empty directory, so that one case still needs an explicit removal. + if (!util.isSymlink(io, link) and util.dirExists(Dir.cwd(), io, link)) { + Dir.cwd().deleteTree(io, link) catch {}; + } + + Dir.cwd().rename(tmp, Dir.cwd(), link, io) catch |e| { + Dir.cwd().deleteFile(io, tmp) catch {}; + prompt.warn(std.fmt.allocPrint( + allocator, + "{s}: could not link into the project ({t}) — the plugin is in the store but this project cannot see it.", + .{ folder, e }, + ) catch folder); + return e; + }; +} + /// Record an outcome in the project's lock file. pub fn recordInLock( allocator: std.mem.Allocator, @@ -260,6 +759,12 @@ pub fn report(allocator: std.mem.Allocator, name: []const u8, outcome: Outcome, prompt.muted(try std.fmt.allocPrint(allocator, "up to date {s} {s}", .{ name, e.version })); return 0; }, + .linked => |e| { + // Say what happened: the project gained the plugin, it just did not + // need downloading because another project already had that version. + prompt.ok(try std.fmt.allocPrint(allocator, "linked {s} {s} (already in the store)", .{ name, e.version })); + return 0; + }, .updated => |u| { prompt.ok(try std.fmt.allocPrint(allocator, "{s}{s} {s} → {s}", .{ if (dry_run) "would update " else "updated ", diff --git a/tools/src/lib/plugin_registry.zig b/tools/src/lib/plugin_registry.zig index 286f3a3..699f00e 100644 --- a/tools/src/lib/plugin_registry.zig +++ b/tools/src/lib/plugin_registry.zig @@ -51,6 +51,26 @@ pub fn slugFor(allocator: std.mem.Allocator, folder: []const u8) ![]const u8 { return util.lower(allocator, folder); } +/// The canonical FOLDER name for a plugin, whatever spelling the user typed. +/// +/// The install directory has to match the PSR-4 namespace exactly: `Plugins\` +/// maps to plugins/, so `Plugins\Crypto\Provider` must live in plugins/Crypto. +/// Installing to whatever the user typed meant `hkm plugins install crypto` +/// produced plugins/crypto — the files were there, the autoloader could not see +/// them, and the failure surfaced later as a missing Provider class. +/// +/// Resolution order: an override table entry (matched on either spelling), then +/// studly-case. The table is what makes `oauth2` → `OAuth2` and `siteseo` → +/// `SiteSEO` rather than the `Oauth2` / `Siteseo` studly-case would produce. +pub fn canonicalName(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + for (slug_overrides) |o| { + if (util.eqlIgnoreCase(input, o.folder) or util.eqlIgnoreCase(input, o.slug)) { + return allocator.dupe(u8, o.folder); + } + } + return util.studly(allocator, input); +} + /// The organisation to fetch from. HKM_PLUGIN_ORG lets a fork or a private /// mirror be used without rebuilding the tool. pub fn org(env: *EnvMap) []const u8 { @@ -82,6 +102,116 @@ pub fn remoteFor(allocator: std.mem.Allocator, env: *EnvMap, folder: []const u8) return std.fmt.allocPrint(allocator, "https://github.com/{s}/hkm-plugin-{s}.git", .{ org(env), slug }); } +/// Does this look like a git remote rather than a plugin name? +/// +/// Plugin names are bare identifiers (`auth`, `SocialAuth`), so anything +/// carrying a scheme, an `scp`-style `host:path`, or a filesystem path is a +/// remote the user wants fetched directly. Checked in that order because +/// `git@github.com:Org/repo.git` has no scheme and would otherwise be missed. +pub fn isRemoteUrl(input: []const u8) bool { + const s = std.mem.trim(u8, input, " \t\r\n"); + if (s.len == 0) return false; + + for ([_][]const u8{ "https://", "http://", "ssh://", "git://", "file://" }) |scheme| { + if (std.mem.startsWith(u8, s, scheme)) return true; + } + + // scp-style: user@host:path — the ':' must come after the '@' and be + // followed by something, or it is a plain name with a stray colon. + if (std.mem.indexOfScalar(u8, s, '@')) |at| { + if (std.mem.indexOfScalarPos(u8, s, at, ':')) |colon| { + if (colon + 1 < s.len) return true; + } + } + + // A local clone, bare or otherwise. + if (s[0] == '/' or std.mem.startsWith(u8, s, "./") or std.mem.startsWith(u8, s, "../")) return true; + + return false; +} + +/// The plugin FOLDER name implied by a remote URL. +/// +/// Takes the repository basename, drops a `.git` suffix and the `hkm-plugin-` +/// prefix the first-party repos carry, then canonicalises — so +/// `https://github.com/AlfaCode-Team/hkm-plugin-social-auth.git` yields +/// `SocialAuth`, exactly as `hkm plugins install social-auth` would. +/// +/// This is a starting guess, not the final answer: the authority on a plugin's +/// name is the `name` field of its own module.json, which cannot be read until +/// the repository has been fetched. The installer re-checks it there and moves +/// the plugin if the two disagree — a repo whose directory name does not match +/// its namespace would otherwise install to a path PSR-4 never looks in. +pub fn nameFromRemote(allocator: std.mem.Allocator, url: []const u8) ![]const u8 { + var s = std.mem.trim(u8, url, " \t\r\n"); + s = std.mem.trimEnd(u8, s, "/"); + + // Basename, for either separator: scp-style remotes use ':' before the path. + if (std.mem.lastIndexOfAny(u8, s, "/:")) |i| s = s[i + 1 ..]; + + if (std.mem.endsWith(u8, s, ".git")) s = s[0 .. s.len - 4]; + if (std.mem.startsWith(u8, s, "hkm-plugin-")) s = s["hkm-plugin-".len ..]; + + if (s.len == 0) return error.UnnamedRemote; + return canonicalName(allocator, s); +} + +/// Is an EXPLICIT remote one of the first-party packages? +/// +/// Same question as `isFirstParty`, asked of a URL the user supplied rather +/// than of the environment — it decides whether the plugin lands in the shared +/// kernel or in the project. The answer is yes only for the configured org's +/// `hkm-plugin-*` repositories on github: a fork, a mirror, or anything else is +/// the project's business, and installing it into the kernel would impose one +/// project's choice on every other project on the machine. +pub fn remoteIsFirstParty(env: *EnvMap, url: []const u8) bool { + const s = std.mem.trim(u8, url, " \t\r\n"); + if (std.mem.indexOf(u8, s, "github.com") == null) return false; + + // The org must be the path segment immediately BEFORE the repo, not merely + // present somewhere in the URL — otherwise a mirror at + // git.example.com/AlfaCode-Team/… would pass by containing the name. + // Matched by hand rather than by building "/hkm-plugin-": this is + // called from paths that have no allocator to spare and no place to free. + const at = std.mem.indexOf(u8, s, "/hkm-plugin-") orelse return false; + const before = s[0..at]; + const o = org(env); + if (before.len < o.len) return false; + if (!std.mem.eql(u8, before[before.len - o.len ..], o)) return false; + + // What precedes the org must be a separator, so "not-AlfaCode-Team" fails. + if (before.len == o.len) return true; + const sep = before[before.len - o.len - 1]; + return sep == '/' or sep == ':'; +} + +/// Is this plugin one of the first-party AlfaCode-Team packages? +/// +/// Decides WHERE it installs, which in turn decides which composer autoloader +/// resolves it: +/// +/// first-party -> /plugins — the kernel's composer maps Plugins\ +/// there, so one copy serves every +/// project on the machine. +/// third-party -> /plugins — the project's own composer maps +/// Plugins\ there, so it stays local to +/// the project that asked for it. +/// +/// Both autoloaders are registered at runtime and each resolves its own +/// directory, so the two never collide. +/// +/// "First-party" means the remote resolves to the default org with no override. +/// Pointing HKM_PLUGIN_ORG or HKM_PLUGIN_REMOTE elsewhere makes it third-party +/// by definition: it is no longer a package this kernel vouches for, and +/// installing it into the shared kernel would impose one user's fork on every +/// project on the machine. +pub fn isFirstParty(env: *EnvMap) bool { + if (env.get("HKM_PLUGIN_REMOTE")) |t| { + if (std.mem.trim(u8, t, " \t\r\n").len > 0) return false; + } + return std.mem.eql(u8, org(env), default_org); +} + /// The running kernel's version, parsed. Null when the build stamped something /// unparseable (never expected — the default is "0.0.0-dev"). pub fn kernelVersion() ?semver.Version { @@ -187,3 +317,55 @@ test "a malformed constraint is refused rather than ignored" { const c = checkKernel(">=not-a-version"); try std.testing.expect(c == .bad_constraint); } + +test "a remote URL is told apart from a plugin name" { + // Names are bare identifiers; anything with a scheme, an scp-style + // host:path, or a filesystem path is a remote. + try std.testing.expect(isRemoteUrl("https://github.com/AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(isRemoteUrl("http://git.internal/hkm/logger.git")); + try std.testing.expect(isRemoteUrl("ssh://git@host/team/logger.git")); + try std.testing.expect(isRemoteUrl("git@github.com:AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(isRemoteUrl("/srv/git/logger.git")); + try std.testing.expect(isRemoteUrl("./vendor-fork")); + + try std.testing.expect(!isRemoteUrl("logger")); + try std.testing.expect(!isRemoteUrl("SocialAuth")); + try std.testing.expect(!isRemoteUrl("")); +} + +test "the plugin name comes out of the repository name" { + const a = std.testing.allocator; + + // The hkm-plugin- prefix and the .git suffix are both dropped, and the + // result goes through canonicalName — so a URL install lands in exactly the + // same directory as installing the same plugin by name. + const cases = [_]struct { url: []const u8, want: []const u8 }{ + .{ .url = "https://github.com/AlfaCode-Team/hkm-plugin-logger.git", .want = "Logger" }, + .{ .url = "https://github.com/AlfaCode-Team/hkm-plugin-social-auth.git", .want = "SocialAuth" }, + .{ .url = "https://github.com/AlfaCode-Team/hkm-plugin-oauth2", .want = "OAuth2" }, + .{ .url = "git@github.com:AlfaCode-Team/hkm-plugin-siteseo.git", .want = "SiteSEO" }, + // Trailing slash, and a repo that carries no prefix at all. + .{ .url = "https://example.com/team/billing/", .want = "Billing" }, + }; + for (cases) |c| { + const got = try nameFromRemote(a, c.url); + defer a.free(got); + try std.testing.expectEqualStrings(c.want, got); + } +} + +test "only the configured org's plugin repos count as first-party" { + var env = EnvMap.init(std.testing.allocator); + defer env.deinit(); + + // First-party decides that the plugin lands in the SHARED kernel, where it + // affects every project on the machine — so a fork must not qualify merely + // by being a copy of one. + try std.testing.expect(remoteIsFirstParty(&env, "https://github.com/AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(remoteIsFirstParty(&env, "git@github.com:AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(!remoteIsFirstParty(&env, "https://github.com/someone-else/hkm-plugin-logger.git")); + // Contains the org name, but as a suffix of a different one. + try std.testing.expect(!remoteIsFirstParty(&env, "https://github.com/not-AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(!remoteIsFirstParty(&env, "https://git.internal/AlfaCode-Team/hkm-plugin-logger.git")); + try std.testing.expect(!remoteIsFirstParty(&env, "/srv/git/hkm-plugin-logger.git")); +} diff --git a/tools/src/lib/plugin_sources.zig b/tools/src/lib/plugin_sources.zig index 3113e78..33c0c0d 100644 --- a/tools/src/lib/plugin_sources.zig +++ b/tools/src/lib/plugin_sources.zig @@ -152,25 +152,88 @@ pub fn listPluginDirs(allocator: std.mem.Allocator, io: Io, pluginsDir: []const defer d.close(io); var it = d.iterate(); while (try it.next(io)) |entry| { - if (entry.kind != .directory) continue; if (entry.name.len > 0 and entry.name[0] == '.') continue; + + // A SYMLINK to a plugin counts. Projects reference a version in the + // shared store by linking it into their own plugins/, and a plain + // `kind != .directory` check reports that entry as `.sym_link` and + // skips it — making every store-linked plugin invisible to discovery, + // and to everything built on it (locate, the dependency catalogue, + // asset publishing, `plugins list`). + switch (entry.kind) { + .directory => {}, + .sym_link => { + // Only follow links that actually resolve to a directory, so a + // dangling or file link is not mistaken for a plugin. + const target = std.fmt.allocPrint(allocator, "{s}/{s}", .{ util.trimSlash(pluginsDir), entry.name }) catch continue; + if (!util.dirExists(Dir.cwd(), io, target)) continue; + }, + else => continue, + } + try out.append(allocator, try allocator.dupe(u8, entry.name)); } } // ── module.json ──────────────────────────────────────────────────────────────── +/// One entry of a module's `requires[]`. +/// +/// A dependency is named by DOMAIN, never by repository — that is the framework +/// being right: a module depends on a capability, not on who ships it. It does +/// leave a plugin outside the platform's own catalogue with no way to say where +/// its dependency comes from, so an entry may also be an object carrying that: +/// +/// "requires": [ +/// "database.management", +/// { "domain": "telemetry.exotic", +/// "repo": "https://github.com/acme/hkm-plugin-telemetry.git", +/// "version": "^1.2" } +/// ] +/// +/// The string form stays exactly as it was — every existing module.json parses +/// unchanged, and first-party plugins have no reason to write the long form. +pub const Requirement = struct { + domain: []const u8, + /// Where to fetch the plugin providing `domain`, for domains this platform + /// has never heard of. Empty when the entry was a plain string. + repo: []const u8 = "", + /// A semver constraint ("^1.2"), an exact tag ("v1.2.0"), or a branch + /// ("main"). Empty means the newest release tag. + version: []const u8 = "", +}; + pub const ModuleMeta = struct { name: ?[]const u8 = null, solves: ?[]const u8 = null, version: ?[]const u8 = null, - /// "requires" — the domains this module depends on (each a `solves` value of - /// another module, or a kernel port). Empty when absent. - requires: []const []const u8 = &.{}, + /// "requires" — what this module depends on. Empty when absent. + requires: []const Requirement = &.{}, + /// Domains named by INDIVIDUAL ROUTES (`routes[].requires[]`). + /// + /// Kept separate because they mean something different to the KERNEL — a + /// route-level entry is seeded into that one request's graph, not every + /// request's — but they are just as mandatory: CompileRouteManifestStage + /// fails the whole boot when a route names a domain no registered module + /// solves. A plugin whose routes require http.pageflow needs Pageflow + /// installed and enabled exactly as much as one that requires it up top. + route_requires: []const Requirement = &.{}, /// "documentation" — preferred enable-time doc (string, or array joined). doc: ?[]const u8 = null, /// "description" — fallback doc text. description: ?[]const u8 = null, + /// "activation" — "essential" when the plugin only works if it is + /// registered into EVERY request. + /// + /// Most plugins are on-demand: the kernel loads them when a route needs + /// them, and that is strictly better. A few cannot be — a plugin whose + /// pipeline stage runs on every request needs its bindings present on every + /// request, and enabling it on-demand produces a project that installs + /// cleanly, boots cleanly, and throws at the first request instead + /// ("no TenantIdentifier is bound for this request"). Declaring it here + /// lets `hkm plugins enable` put it in the right list without the user + /// having to know. + activation: ?[]const u8 = null, /// "kernel" — semver constraint on the kernel this plugin supports /// (e.g. "^1.0"). Absent means "no opinion" and never blocks installation; /// see plugin_registry.checkKernel. @@ -191,14 +254,140 @@ pub fn readModuleMeta(allocator: std.mem.Allocator, io: Io, pluginsDir: []const .name = strField(parsed.object, "name"), .solves = strField(parsed.object, "solves"), .version = strField(parsed.object, "version"), - .requires = try strArrayField(allocator, parsed.object, "requires"), + .requires = try requiresField(allocator, parsed.object), + .route_requires = try routeRequiresField(allocator, parsed.object), .doc = try docField(allocator, parsed.object, "documentation"), .description = strField(parsed.object, "description"), .kernel = strField(parsed.object, "kernel"), + .activation = strField(parsed.object, "activation"), }; } -/// Read an array-of-strings field (e.g. "requires"). Returns an empty slice when +/// Read "requires", accepting both the string and the object form. +/// +/// An object without a usable "domain" is SKIPPED rather than defaulted: a +/// requirement whose domain could not be read cannot be resolved, satisfied or +/// reported, and inventing one would attach its repo to the wrong dependency. +fn requiresField(allocator: std.mem.Allocator, obj: std.json.ObjectMap) ![]const Requirement { + const v = obj.get("requires") orelse return &.{}; + if (v != .array) return &.{}; + + var out: std.ArrayList(Requirement) = .empty; + for (v.array.items) |item| { + // "tag" and "branch" are the same field to git — one ref to clone at. + // Named separately because a manifest saying "branch": "main" reads + // better than "version": "main". + const parsed = parseRequirement(item) orelse continue; + try out.append(allocator, parsed); + } + return out.toOwnedSlice(allocator); +} + +/// Collect every domain named by a route-level `requires[]`, de-duplicated. +/// +/// Routes reach the manifest by three paths, and a dependency declared on ANY of +/// them is equally mandatory — the boot fails when a route names a domain no +/// registered module solves, wherever that route was written: +/// +/// "routeRequires": [...] a module-wide default applied to every route +/// "routes": [ { "requires" } ] a route declared at the top level +/// "groups": [ { "requires", "routes": [...], "groups": [...] } ] nested +/// +/// Missing the grouped ones would let `hkm plugins enable` resolve a plugin's +/// dependencies, install them, and still produce a project that fails at boot. +fn routeRequiresField(allocator: std.mem.Allocator, obj: std.json.ObjectMap) ![]const Requirement { + var out: std.ArrayList(Requirement) = .empty; + try collectRequires(allocator, obj, &out, 0); + return out.toOwnedSlice(allocator); +} + +/// Matches CompileRouteManifestStage::MAX_GROUP_DEPTH — a self-referencing +/// structure is rejected there, and must not spin here either. +const max_group_depth: u8 = 16; + +/// Walk one route-declaration source: its module-wide `routeRequires`, each +/// `routes[].requires[]`, and every nested group, recursively. +fn collectRequires( + allocator: std.mem.Allocator, + obj: std.json.ObjectMap, + out: *std.ArrayList(Requirement), + depth: u8, +) !void { + if (depth > max_group_depth) return; + + // Module-wide / group-wide default. + if (obj.get("routeRequires")) |v| try appendRequirements(allocator, v, out); + if (obj.get("requires")) |v| { + // Only meaningful on a GROUP — a module's own top-level requires[] is + // read separately by requiresField(). Harmless either way: duplicates + // are dropped, and both lists name domains that must be installed. + if (depth > 0) try appendRequirements(allocator, v, out); + } + + if (obj.get("routes")) |routes| { + if (routes == .array) { + for (routes.array.items) |route| { + if (route != .object) continue; + if (route.object.get("requires")) |reqs| { + try appendRequirements(allocator, reqs, out); + } + } + } + } + + if (obj.get("groups")) |groups| { + if (groups == .array) { + for (groups.array.items) |group| { + if (group != .object) continue; + try collectRequires(allocator, group.object, out, depth + 1); + } + } + } +} + +/// Append every requirement in a `requires[]` value, skipping duplicates. +fn appendRequirements( + allocator: std.mem.Allocator, + value: std.json.Value, + out: *std.ArrayList(Requirement), +) !void { + if (value != .array) return; + + for (value.array.items) |item| { + const parsed = parseRequirement(item) orelse continue; + var seen = false; + for (out.items) |e| { + if (std.mem.eql(u8, e.domain, parsed.domain)) seen = true; + } + if (!seen) try out.append(allocator, parsed); + } +} + +/// One requires[] entry, in either the string or the object form. +fn parseRequirement(item: std.json.Value) ?Requirement { + switch (item) { + .string => { + if (item.string.len == 0) return null; + return .{ .domain = item.string }; + }, + .object => { + const domain = strField(item.object, "domain") orelse return null; + if (domain.len == 0) return null; + return .{ + .domain = domain, + .repo = strField(item.object, "repo") orelse + strField(item.object, "remote") orelse + strField(item.object, "url") orelse "", + .version = strField(item.object, "version") orelse + strField(item.object, "tag") orelse + strField(item.object, "branch") orelse "", + }; + }, + else => return null, + } +} + +/// Read an array-of-strings field. Returns an empty slice when /// absent, not an array, or empty. Non-string elements are skipped. fn strArrayField(allocator: std.mem.Allocator, obj: std.json.ObjectMap, key: []const u8) ![]const []const u8 { const v = obj.get(key) orelse return &.{}; @@ -233,3 +422,100 @@ fn docField(allocator: std.mem.Allocator, obj: std.json.ObjectMap, key: []const else => return null, } } + +// ── tests ─────────────────────────────────────────────────────────────────── + +/// Parse a module.json body and collect its route-level requires. +fn testRouteRequires(allocator: std.mem.Allocator, json: []const u8) ![]const Requirement { + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, allocator, json, .{}); + return routeRequiresField(allocator, parsed.object); +} + +fn hasDomain(list: []const Requirement, domain: []const u8) bool { + for (list) |r| { + if (std.mem.eql(u8, r.domain, domain)) return true; + } + return false; +} + +test "route requires are collected from top-level routes" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const got = try testRouteRequires(arena.allocator(), + \\{ "routes": [ { "requires": ["view.rendering"] } ] } + ); + + try std.testing.expect(hasDomain(got, "view.rendering")); +} + +test "route requires are collected from NESTED groups" { + // A plugin that moves its routes into groups[] declares the same mandatory + // dependencies — missing them would install cleanly and fail at boot. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const got = try testRouteRequires(arena.allocator(), + \\{ + \\ "routeRequires": ["database.management"], + \\ "routes": [ { "requires": ["http.client"] } ], + \\ "groups": [ + \\ { "requires": ["audit.trail"], + \\ "routes": [ { "requires": ["storage.local"] } ], + \\ "groups": [ { "routes": [ { "requires": ["view.rendering"] } ] } ] } + \\ ] + \\} + ); + + try std.testing.expect(hasDomain(got, "database.management")); // module-wide default + try std.testing.expect(hasDomain(got, "http.client")); // top-level route + try std.testing.expect(hasDomain(got, "audit.trail")); // the group itself + try std.testing.expect(hasDomain(got, "storage.local")); // a route inside it + try std.testing.expect(hasDomain(got, "view.rendering")); // a nested group +} + +test "a module's own top-level requires is not double-counted here" { + // requiresField() already reads it; collecting it again would be harmless + // but muddies which list a domain came from. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const got = try testRouteRequires(arena.allocator(), + \\{ "requires": ["crypto.services"], "routes": [] } + ); + + try std.testing.expect(!hasDomain(got, "crypto.services")); +} + +test "duplicate domains across groups are collected once" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + const got = try testRouteRequires(arena.allocator(), + \\{ + \\ "routes": [ { "requires": ["view.rendering"] } ], + \\ "groups": [ { "routes": [ { "requires": ["view.rendering"] } ] } ] + \\} + ); + + var count: usize = 0; + for (got) |r| { + if (std.mem.eql(u8, r.domain, "view.rendering")) count += 1; + } + try std.testing.expectEqual(@as(usize, 1), count); +} + +test "a self-referencing group structure terminates" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + + // 40 levels — deeper than max_group_depth, which the compiler also rejects. + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(std.testing.allocator); + for (0..40) |_| try buf.appendSlice(std.testing.allocator, "{\"groups\":["); + try buf.appendSlice(std.testing.allocator, "{\"routes\":[{\"requires\":[\"deep.domain\"]}]}"); + for (0..40) |_| try buf.appendSlice(std.testing.allocator, "]}"); + + const got = try testRouteRequires(arena.allocator(), buf.items); + _ = got; // reaching here at all is the assertion: it returned. +} diff --git a/tools/src/lib/plugin_store.zig b/tools/src/lib/plugin_store.zig new file mode 100644 index 0000000..95924ac --- /dev/null +++ b/tools/src/lib/plugin_store.zig @@ -0,0 +1,238 @@ +//! The global plugin cache: one download per (plugin, version, origin), +//! shared by every project on the machine. +//! +//! A project does not own the plugins it uses — it references them. Project A +//! installing Auth v1.2.0 downloads it once; project B wanting the same version +//! links at what is already there and downloads nothing. That is the whole +//! point of a store, and it only holds if the store is GLOBAL: when it followed +//! the install target, third-party plugins landed under the project and every +//! project kept its own copy of identical bytes. +//! +//! ## Layout +//! +//! //-/ +//! +//! The version alone is not a safe key. Two repositories can both publish +//! `v1.0.0` of a plugin called Logger — a fork and its upstream, a private +//! mirror and the public original — and keying on the version alone would give +//! the second one the FIRST one's files, silently, with no error anywhere. The +//! hash is of the remote URL, so those are different directories. +//! +//! It is a hash of the ORIGIN rather than of the content because the lookup has +//! to happen BEFORE anything is downloaded — the question "do I already have +//! this?" is asked when all that is known is the remote and the tag. A content +//! hash could only be computed after the download it is meant to avoid. + +const std = @import("std"); +const util = @import("util.zig"); + +const Dir = std.Io.Dir; +const Io = std.Io; +const EnvMap = std.process.Environ.Map; + +/// Directory name under the store root, so it stays recognisable in `ls`. +pub const dir_name = "plugin-store"; + +/// Where the cache lives. +/// +/// Resolution order, most explicit first: +/// +/// 1. HKM_PLUGIN_STORE — env, or `hkm plugins store --set` (which +/// writes it to the same config the launcher +/// loads into the environment) +/// 2. $XDG_CACHE_HOME/hkm/… — the conventional per-user cache +/// 3. $HOME/.cache/hkm/… — same, when XDG_CACHE_HOME is unset +/// 4. /plugin-store — the caller's kernel root, for a machine +/// with no HOME (containers, CI) +/// +/// A cache directory is the right home: the contents are re-downloadable, +/// per-user, and safe for a cleaner to delete — losing it costs a re-fetch, +/// never a project. +pub fn root(allocator: std.mem.Allocator, env: *EnvMap, fallback: []const u8) ![]const u8 { + if (env.get("HKM_PLUGIN_STORE")) |v| { + const t = std.mem.trim(u8, v, " \t\r\n"); + if (t.len > 0) return allocator.dupe(u8, util.trimSlash(t)); + } + + if (env.get("XDG_CACHE_HOME")) |x| { + const t = std.mem.trim(u8, x, " \t\r\n"); + if (t.len > 0) return std.fmt.allocPrint(allocator, "{s}/hkm/{s}", .{ util.trimSlash(t), dir_name }); + } + + if (env.get("HOME")) |h| { + const t = std.mem.trim(u8, h, " \t\r\n"); + if (t.len > 0) return std.fmt.allocPrint(allocator, "{s}/.cache/hkm/{s}", .{ util.trimSlash(t), dir_name }); + } + + return std.fmt.allocPrint(allocator, "{s}/{s}", .{ util.trimSlash(fallback), dir_name }); +} + +/// Short, stable hash of a remote URL. +/// +/// SHA-256 truncated to 8 hex characters. Truncation is fine here: this +/// separates a handful of origins for the same plugin, it is not a security +/// boundary, and a collision would need two remotes whose digests share 32 +/// bits AND that publish the same version of the same plugin name. +/// +/// The URL is normalised first so `…/plugin.git`, `…/plugin` and `…/plugin/` +/// are one entry rather than three copies of identical bytes. +pub fn originHash(allocator: std.mem.Allocator, remote: []const u8) ![]const u8 { + var s = std.mem.trim(u8, remote, " \t\r\n"); + s = std.mem.trimEnd(u8, s, "/"); + if (std.mem.endsWith(u8, s, ".git")) s = s[0 .. s.len - 4]; + + var digest: [32]u8 = undefined; + var h = std.crypto.hash.sha2.Sha256.init(.{}); + // Case-insensitively: a host is case-insensitive, and GitHub treats the + // owner/repo path that way too, so differing only in case is the same repo. + var buf: [256]u8 = undefined; + var i: usize = 0; + while (i < s.len) { + const n = @min(buf.len, s.len - i); + for (0..n) |j| { + const c = s[i + j]; + buf[j] = if (c >= 'A' and c <= 'Z') c - 'A' + 'a' else c; + } + h.update(buf[0..n]); + i += n; + } + h.final(&digest); + + return std.fmt.allocPrint(allocator, "{x}", .{digest[0..4]}); +} + +/// The directory name for one cached version: `-`. +/// +/// An empty remote yields the bare version. That keeps entries written before +/// origin hashing readable, and means a caller with no remote to offer still +/// gets a usable (if less precise) key rather than an error. +pub fn versionKey(allocator: std.mem.Allocator, version: []const u8, remote: []const u8) ![]const u8 { + if (remote.len == 0) return allocator.dupe(u8, version); + const h = try originHash(allocator, remote); + // Freed here rather than left to the caller's arena: this is also called + // from tests and from long-lived loops, where an intermediate that only + // ever gets formatted into the result has no reason to outlive it. + defer allocator.free(h); + return std.fmt.allocPrint(allocator, "{s}-{s}", .{ version, h }); +} + +/// Full path to one cached version of one plugin. +pub fn entryDir( + allocator: std.mem.Allocator, + env: *EnvMap, + fallback: []const u8, + name: []const u8, + version: []const u8, + remote: []const u8, +) ![]const u8 { + const r = try root(allocator, env, fallback); + const key = try versionKey(allocator, version, remote); + return std.fs.path.join(allocator, &.{ r, name, key }); +} + +/// Path to a plugin's directory in the store (all its versions). +pub fn pluginDir( + allocator: std.mem.Allocator, + env: *EnvMap, + fallback: []const u8, + name: []const u8, +) ![]const u8 { + const r = try root(allocator, env, fallback); + return std.fs.path.join(allocator, &.{ r, name }); +} + +/// The VERSION part of a store entry name, without the origin hash. +/// +/// Entry directories are `-`, and the hash is an implementation +/// detail of the cache — showing it in "updated v2.0.0-42f5f5a5 → v2.0.1" +/// exposes a name the user never typed and cannot look up. +pub fn versionOf(entry: []const u8) []const u8 { + const dash = std.mem.lastIndexOfScalar(u8, entry, '-') orelse return entry; + const suffix = entry[dash + 1 ..]; + if (suffix.len != 8) return entry; // not our hash — part of the version + for (suffix) |c| { + const hex = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f'); + if (!hex) return entry; + } + return entry[0..dash]; +} + +/// Does `dir` (a `-` entry name) hold this version, whatever its +/// origin? Used by prune and by "is any copy of this version present" checks, +/// where the origin is not known or does not matter. +pub fn entryIsVersion(entry: []const u8, version: []const u8) bool { + if (std.mem.eql(u8, entry, version)) return true; // pre-hash entry + if (!std.mem.startsWith(u8, entry, version)) return false; + return entry.len > version.len and entry[version.len] == '-'; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "the same repo spelled differently is one cache entry" { + const a = std.testing.allocator; + + const forms = [_][]const u8{ + "https://github.com/AlfaCode-Team/hkm-plugin-logger.git", + "https://github.com/AlfaCode-Team/hkm-plugin-logger", + "https://github.com/AlfaCode-Team/hkm-plugin-logger/", + "https://github.com/alfacode-team/hkm-plugin-logger.git", + }; + const first = try originHash(a, forms[0]); + defer a.free(first); + for (forms[1..]) |f| { + const h = try originHash(a, f); + defer a.free(h); + try std.testing.expectEqualStrings(first, h); + } +} + +test "different origins of the same version never share a directory" { + const a = std.testing.allocator; + + // The failure this prevents: a fork and its upstream both publishing + // v1.0.0, the second silently getting the first's files. + const upstream = try versionKey(a, "v1.0.0", "https://github.com/AlfaCode-Team/hkm-plugin-logger.git"); + defer a.free(upstream); + const fork = try versionKey(a, "v1.0.0", "https://github.com/someone/hkm-plugin-logger.git"); + defer a.free(fork); + + try std.testing.expect(!std.mem.eql(u8, upstream, fork)); + try std.testing.expect(std.mem.startsWith(u8, upstream, "v1.0.0-")); + try std.testing.expect(std.mem.startsWith(u8, fork, "v1.0.0-")); +} + +test "an entry is recognised as its version, hashed or not" { + try std.testing.expect(entryIsVersion("v1.0.0-1a2b3c4d", "v1.0.0")); + try std.testing.expect(entryIsVersion("v1.0.0", "v1.0.0")); // written before hashing + // v1.0.10 must not be read as v1.0.1 — the separator is what stops it. + try std.testing.expect(!entryIsVersion("v1.0.10", "v1.0.1")); + try std.testing.expect(!entryIsVersion("v2.0.0-1a2b3c4d", "v1.0.0")); +} + +test "an explicit HKM_PLUGIN_STORE wins over every default" { + const a = std.testing.allocator; + var env = EnvMap.init(a); + defer env.deinit(); + + try env.put("HOME", "/home/someone"); + const by_home = try root(a, &env, "/opt/hkm"); + defer a.free(by_home); + try std.testing.expectEqualStrings("/home/someone/.cache/hkm/plugin-store", by_home); + + try env.put("HKM_PLUGIN_STORE", "/srv/shared/plugins/"); + const explicit = try root(a, &env, "/opt/hkm"); + defer a.free(explicit); + // Trailing slash trimmed, so joins never produce a doubled separator. + try std.testing.expectEqualStrings("/srv/shared/plugins", explicit); +} + +test "the origin hash is stripped for display" { + try std.testing.expectEqualStrings("v2.0.0", versionOf("v2.0.0-42f5f5a5")); + try std.testing.expectEqualStrings("v2.0.0", versionOf("v2.0.0")); + // A pre-release suffix is part of the version, not a hash. + try std.testing.expectEqualStrings("v1.0.0-beta", versionOf("v1.0.0-beta")); + // Eight chars but not hex. + try std.testing.expectEqualStrings("v1.0.0-zzzzzzzz", versionOf("v1.0.0-zzzzzzzz")); +} diff --git a/tools/src/lib/prompt.zig b/tools/src/lib/prompt.zig index 19e576e..3b1e6ed 100644 --- a/tools/src/lib/prompt.zig +++ b/tools/src/lib/prompt.zig @@ -68,6 +68,16 @@ pub fn section(title: []const u8) void { /// A two-column help row: a cyan key padded to 30 cols, then a dimmed /// description. Use for usage lines, flags, env vars, and examples. pub fn item(key: []const u8, desc: []const u8) void { + // A key longer than the column still needs a gap before its description. + // Without one, every long usage line in `--help` read as one run-on word: + // "hkm plugins enable [proj]wire a plugin into the project". + if (key.len >= 30) { + std.debug.print( + bar ++ " " ++ cyan ++ "{s}" ++ reset ++ " " ++ gray ++ "{s}" ++ reset ++ "\n", + .{ key, desc }, + ); + return; + } std.debug.print( bar ++ " " ++ cyan ++ "{s: <30}" ++ reset ++ gray ++ "{s}" ++ reset ++ "\n", .{ key, desc }, diff --git a/tools/src/lib/util.zig b/tools/src/lib/util.zig index 92c9cc5..8b8b580 100644 --- a/tools/src/lib/util.zig +++ b/tools/src/lib/util.zig @@ -51,6 +51,74 @@ pub fn chmod600(io: Io, path: []const u8) void { f.setPermissions(io, @enumFromInt(0o600)) catch {}; } +/// Is `path` a symbolic link? readLink succeeds only on one. +pub fn isSymlink(io: Io, path: []const u8) bool { + var buf: [std.fs.max_path_bytes]u8 = undefined; + _ = Dir.cwd().readLink(io, path, &buf) catch return false; + return true; +} + +/// snake_case, from any spelling: "AddWidgets"/"addWidgets"/"add widgets" all +/// become "add_widgets". An underscore already present is preserved — which is +/// the whole point, since studly()+lower() destroys it. +pub fn snake(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + var prev_lower = false; + for (input) |c| { + if (c == ' ' or c == '-' or c == '.' or c == '/') { + if (out.items.len > 0 and out.items[out.items.len - 1] != '_') try out.append(allocator, '_'); + prev_lower = false; + continue; + } + if (c >= 'A' and c <= 'Z') { + // Boundary only after a lower-case run, so "HTTPClient" does not + // become "h_t_t_p_client". + if (prev_lower and out.items.len > 0) try out.append(allocator, '_'); + try out.append(allocator, c - 'A' + 'a'); + prev_lower = false; + continue; + } + try out.append(allocator, c); + prev_lower = (c >= 'a' and c <= 'z') or (c >= '0' and c <= '9'); + } + return out.toOwnedSlice(allocator); +} + +/// Drop `suffix` from the end of `name`, case-insensitively. Used so +/// `make:seeder WidgetSeeder` produces WidgetSeeder.php, not +/// WidgetSeederSeeder.php. +pub fn stripSuffix(name: []const u8, suffix: []const u8) []const u8 { + if (name.len <= suffix.len) return name; + const tail = name[name.len - suffix.len ..]; + var i: usize = 0; + while (i < suffix.len) : (i += 1) { + const a = if (tail[i] >= 'A' and tail[i] <= 'Z') tail[i] - 'A' + 'a' else tail[i]; + const b = if (suffix[i] >= 'A' and suffix[i] <= 'Z') suffix[i] - 'A' + 'a' else suffix[i]; + if (a != b) return name; + } + return name[0 .. name.len - suffix.len]; +} + +/// Where a symlink points, duped into `allocator`; null when `path` is not one. +pub fn linkTarget(allocator: std.mem.Allocator, io: Io, path: []const u8) ?[]const u8 { + var buf: [std.fs.max_path_bytes]u8 = undefined; + // readLink returns the byte count written into the buffer, not a slice. + const n = Dir.cwd().readLink(io, path, &buf) catch return null; + return allocator.dupe(u8, buf[0..n]) catch null; +} + +/// Make a file executable (0755). No-op on Windows. +/// +/// A plain read-then-write copy does NOT carry the mode across, so a launcher +/// copied that way lands as 0644 and cannot be run — the install looks like it +/// worked right up until the first invocation. +pub fn chmodExec(io: Io, path: []const u8) void { + if (@import("builtin").os.tag == .windows) return; + const f = Dir.cwd().openFile(io, path, .{}) catch return; + defer f.close(io); + f.setPermissions(io, @enumFromInt(0o755)) catch {}; +} + // ── path strings ──────────────────────────────────────────────────────────── /// Trim trailing path separators (keeps a lone "/"). @@ -230,3 +298,27 @@ pub fn appendJsonString(allocator: std.mem.Allocator, out: *std.ArrayList(u8), s try out.append(allocator, '"'); } +test "snake_case keeps underscores the caller already wrote" { + const a = std.testing.allocator; + const cases = [_]struct { in: []const u8, want: []const u8 }{ + .{ .in = "add_widgets", .want = "add_widgets" }, + .{ .in = "AddWidgets", .want = "add_widgets" }, + .{ .in = "addWidgets", .want = "add_widgets" }, + .{ .in = "add widgets", .want = "add_widgets" }, + .{ .in = "add_widgets_to_orders", .want = "add_widgets_to_orders" }, + .{ .in = "widgets", .want = "widgets" }, + }; + for (cases) |c| { + const got = try snake(a, c.in); + defer a.free(got); + try std.testing.expectEqualStrings(c.want, got); + } +} + +test "an existing suffix is not doubled" { + try std.testing.expectEqualStrings("Widget", stripSuffix("WidgetSeeder", "Seeder")); + try std.testing.expectEqualStrings("Widget", stripSuffix("Widgetseeder", "Seeder")); + try std.testing.expectEqualStrings("Widget", stripSuffix("Widget", "Seeder")); + // Not a suffix, merely a substring. + try std.testing.expectEqualStrings("SeederThing", stripSuffix("SeederThing", "Seeder")); +} diff --git a/tools/src/main.zig b/tools/src/main.zig index 0c0a56c..8f1cfbd 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -17,8 +17,8 @@ const banner = @import("lib/banner.zig"); const prompt = @import("lib/prompt.zig"); const memory = @import("lib/memory.zig"); -fn printHelp() void { - banner.print(); +fn printHelp(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ.Map) void { + banner.print(allocator, io, env); prompt.section("Usage"); prompt.item("hkm new [opts]", "scaffold a new PhpServicePlatform project"); @@ -239,13 +239,13 @@ fn dispatch(init: std.process.Init.Minimal, mm: *memory.Manager) !u8 { } if (args.len <= 1) { - printHelp(); + printHelp(allocator, io, &env_map); return 0; } const cmd = args[1]; if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "--help") or std.mem.eql(u8, cmd, "-h")) { - printHelp(); + printHelp(allocator, io, &env_map); return 0; } if (std.mem.eql(u8, cmd, "--version") or std.mem.eql(u8, cmd, "-v")) { @@ -253,7 +253,7 @@ fn dispatch(init: std.process.Init.Minimal, mm: *memory.Manager) !u8 { return 0; } if (std.mem.eql(u8, cmd, "version")) { - banner.print(); + banner.print(allocator, io, &env_map); return 0; } if (std.mem.eql(u8, cmd, "upgrade") or std.mem.eql(u8, cmd, "self-update")) { diff --git a/tools/src/stamp.zig b/tools/src/stamp.zig index 3b30e0b..b68ff2c 100644 --- a/tools/src/stamp.zig +++ b/tools/src/stamp.zig @@ -71,6 +71,13 @@ pub fn main(init: std.process.Init.Minimal) !void { // with the tag it was built from — the field is simply left out. It is // optional; a broken install is not. if (!composerValid(version)) { + // A `git describe` version ("1.1.0-dev.2-12-g29dccfb") is what every + // build from a checkout between releases looks like. It is EXPECTED to + // be unstampable, so saying so on every single dev build trains people + // to ignore the message — and then they ignore it on the release build + // where it matters. Skip quietly for that shape; warn for anything else. + if (isDescribeVersion(version)) return; + var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &buf, @@ -90,6 +97,28 @@ pub fn main(init: std.process.Init.Minimal) !void { try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = updated }); } +/// Does this look like `git describe` output — "--g"? +/// +/// Matched on the trailing "--g" only, so a real pre-release +/// ("1.1.0-beta.1") is not mistaken for one and still gets the warning. +fn isDescribeVersion(v: []const u8) bool { + const g = std.mem.lastIndexOfScalar(u8, v, '-') orelse return false; + const sha = v[g + 1 ..]; + if (sha.len < 2 or sha[0] != 'g') return false; + for (sha[1..]) |c| { + if (!std.ascii.isHex(c)) return false; + } + + const head = v[0..g]; + const d = std.mem.lastIndexOfScalar(u8, head, '-') orelse return false; + const count = head[d + 1 ..]; + if (count.len == 0) return false; + for (count) |c| { + if (!std.ascii.isDigit(c)) return false; + } + return true; +} + /// Whether Composer will accept this as a package version. /// /// A deliberately CONSERVATIVE subset of Composer's own pattern: numeric parts, @@ -343,3 +372,15 @@ test "a version ending in 'v' keeps its last character" { try std.testing.expect(composerValid("1.1.0-dev")); try std.testing.expect(!composerValid("1.1.0-de")); } + +test "a git describe version is recognised so dev builds stay quiet" { + try std.testing.expect(isDescribeVersion("1.1.0-dev.2-12-g29dccfb")); + try std.testing.expect(isDescribeVersion("1.0.21-138-gbdbbf34")); + + // A real pre-release must NOT be mistaken for one: those are release + // intents, and silently skipping them is how a release ships unstamped. + try std.testing.expect(!isDescribeVersion("1.1.0-beta.1")); + try std.testing.expect(!isDescribeVersion("1.1.0-dev.2")); + try std.testing.expect(!isDescribeVersion("1.1.0")); + try std.testing.expect(!isDescribeVersion("1.1.0-12-gzz")); +} diff --git a/tools/src/templates/app/bootstrap/app.php b/tools/src/templates/app/bootstrap/app.php index 3a4ce5c..01b0811 100644 --- a/tools/src/templates/app/bootstrap/app.php +++ b/tools/src/templates/app/bootstrap/app.php @@ -82,19 +82,16 @@ // Plugins — module providers (registered into the kernel below). use Plugins\Crypto\Provider as CryptoProvider; -use Plugins\I18n\Provider as I18nProvider; +use Plugins\Logger\Provider as LoggerProvider; use Plugins\Database\Provider as DatabaseProvider; use Plugins\Commands\Provider as CommandsProvider; use Plugins\Storage\Provider as StorageProvider; -use Plugins\HttpClient\Provider as HttpClientProvider; +use Plugins\Validation\Provider as ValidationProvider; use Plugins\Session\Provider as SessionProvider; use Plugins\Cookie\Provider as CookieProvider; use Plugins\RedisCache\Provider as RedisCacheProvider; -use Plugins\SiteSEO\Application\Listeners\EnqueueIndexNowListener; -use Plugins\SiteSEO\Provider as SiteSeoModule; use Plugins\View\Provider as ViewModule; use Plugins\SecurityFilters\Provider as SecurityFiltersModule; -use Plugins\Edge\Provider as EdgeProvider; // Flat layout: this directory's grandparent is the project root. @@ -190,12 +187,16 @@ // when REDIS_HOST is set. Lets `php app/worker/run.php` drain real jobs. QueuePort::class => static fn(): FileQueue => new FileQueue($projectRoot . '/var/queue'), - // The SEO module subscribes EnqueueIndexNowListener to seo.url_published, but - // the EventBus resolves listeners from the CoreContainer — so bind it here - // with the QueuePort. (The factory receives the container.) - EnqueueIndexNowListener::class => static fn($c) => new EnqueueIndexNowListener( - $c->make(QueuePort::class), - ), + // ── When you enable the User + Tenancy plugins ─────────────────────────── + // The User plugin subscribes ProvisionTenantProfileListener to user.registered + // to write the per-tenant user_profiles row. The EventBus resolves listeners + // from the CoreContainer, so bind it here WITH Tenancy's connection resolver + // (same pattern as the SEO listener above). Left unbound it safely no-ops. + // + // \Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener::class + // => static fn($c) => new \Plugins\User\Infrastructure\Listeners\ProvisionTenantProfileListener( + // $c->make(\Plugins\Tenancy\API\Contracts\TenantConnectionResolverContract::class), + // ), ]; if (filter_var($env('DB_POOL_ENABLED', 'false'), FILTER_VALIDATE_BOOL)) { @@ -234,6 +235,21 @@ // the synthetic '__project__' scope — no module register() runs for them. // Keep these controllers thin; real domain logic lives in plugins. ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Route GROUPS from proj.json: a prefix / filters / requires / name + // prefix / SITE stated once for every route inside the group, and + // expanded into flat routes at boot. `site` is part of the route key, + // so one project can answer `GET /` differently per group of hosts. + ->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) + // The hosts this project serves. A route grouped under a domain that is + // not in proj.json "domains" fails the boot — nothing could ever reach it. + ->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) + + // Project ROUTE POLICY declared in proj.json ("routePolicy": {"disable": []}). + // A plugin OWNS its routes, but the project is the final authority: it can + // veto specific plugin routes ("METHOD /path") or a whole plugin's routes (a + // module domain) without forking the plugin. Applied to plugin routes before + // project routes compile — an unmatched spec fails the boot. + ->withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot)) // Security layers run BEFORE any module loads — a denied request costs zero // module work. CsrfTokenLayer here is a stateless, HMAC-signed token @@ -255,21 +271,30 @@ // in. Use for capabilities only SOME routes need (views, outbound HTTP, // storage). A route opts in via its "requires" in proj.json / module.json. ->withModules([ - // Crypto (solves: crypto) — provides the concrete AesEncrypter and + // Logger (solves: logging.application) — supplies the LoggerPort adapter. + // Channel/level come from config/logger.php (LOG_CHANNEL, LOG_LEVEL, + // LOG_FILE). Keep this registered: components that log (Database, + // Tenancy, EventBus, command auditing) degrade to silence without it, + // and silent logging is indistinguishable from nothing having happened. + LoggerProvider::class, + + // Crypto (solves: crypto.services) — provides the concrete AesEncrypter and // PasswordHasher classes behind the Encryption/Hashing port factories, // plus crypto helpers other modules consume. CryptoProvider::class, - // I18n (solves: i18n) — translation/localisation: message catalogues, - // locale negotiation, and the translator used by modules and views. - I18nProvider::class, + // Validation (solves: validation.rules) — the shared request-validation + // engine. Its boot() loads config/validation.php and registers the + // CommonRules + FinancialRules packs. DTOs extend Plugins\Validation\ + // AbstractDto; built-in rules work without this, the packs need it. + ValidationProvider::class, - // Database (solves: database.query) — the multi-driver database stack: + // Database (solves: database.management) — the multi-driver database stack: // the DatabasePort adapter, the pooled adapter that borrows from the // ConnectionPool, and connection/schema management. DatabaseProvider::class, - // Commands (solves: commands) — registers this project's console + // Commands (solves: system.commands) — registers this project's console // commands into the CLI pipeline (run via `php app/cli/run.php`). CommandsProvider::class, @@ -279,25 +304,37 @@ // "requires": ["storage.local"]. StorageProvider::class, - // HttpClient (solves: http.client) — the HttpClientPort for OUTBOUND - // HTTP (calling third-party APIs from gateways). Required by SiteSEO. - HttpClientProvider::class, - // View (solves: view.rendering) — server-side PHP templating: layouts, // sections, the project-first view cascade and `namespace::view` // resolution. Routes opt in via "requires": ["view.rendering"]. ViewModule::class, - // SiteSEO (solves: seo.management) — SEO toolkit: sitemaps, Open Graph, - // JSON-LD, robots, IndexNow. Exposes SeoServiceContract + the /api/seo/* - // routes. Needs http.client (above) for its network actions. - SiteSeoModule::class, - - // Edge (solves: edge.routing) — generates the host's web-server front - // config (nginx SNI stream splitter / nginx-only / Apache vhost) from the - // platform's registered domains. CLI-first: `hkm edge:status`, - // `hkm edge:apply`. Routes opt in via "requires": ["edge.routing"]. - EdgeProvider::class, + // Edge (solves: edge.routing) — generates this host's web-server front + // config from the project's domains: an nginx SNI stream splitter when + // nginx+Apache both run, else a plain nginx/Apache vhost (docroot + // app/public, PHP-FPM or Swoole) with the run-env injected. Local + // (.local/.test) domains go to /etc/hosts instead (dev only). + // CLI: `hkm cli -p edge:status | edge:apply | edge:hosts`. + \Plugins\Edge\Provider::class, + + // ── Not installed — add when you need them ─────────────────────── + // Each is one command; it fetches the plugin, its dependencies, and + // wires them into this list for you. + // + // hkm plugins install i18n // i18n.translation — __(), locales + // hkm plugins install http-client // http.client — outbound HTTP + // hkm plugins install siteseo // seo.management — sitemaps, JSON-LD + // // (also needs http-client, and a + // // QueuePort-bound EnqueueIndexNowListener + // // in withPorts() for index-on-publish) + + // Identity stack (enable together in an app that needs accounts): + // \Plugins\User\Provider::class, // user.management (identity + settings) + // \Plugins\Feedback\Provider::class, // feedback.management (/ajx/feedback) + // \Plugins\Auth\Provider::class, // auth.identity (login/tokens) + // \Plugins\Tenancy\Provider::class, // tenancy.routing (multi-tenant) + // The User plugin queues a verification email on signup ONLY when a + // MailPort is bound in withPorts() above (else it is skipped). ]) // ESSENTIAL modules: registered into EVERY request container regardless of @@ -326,6 +363,22 @@ SecurityFiltersModule::class, ]) + // PROJECT-DECLARED essentials from proj.json ("essentials": [ ... ]) — each + // entry is a module DOMAIN (a plugin's solves value). This is the project's + // lever for which plugins are global WITHOUT editing this file: e.g. a + // multi-tenant project declares "tenancy.routing" here, a single-tenant one + // simply doesn't. The named module must be in withModules() above; the + // kernel resolves the domain at build() and an unknown domain FAILS the + // boot (never a silent no-op). Keep this list SHORT — every essential (and + // its requires[] graph) registers on every request. + // + // Session-cookie login: Auth's SessionAuthStage resolves the logged-in user + // on a route ONLY when auth.identity + user.management are in that request's + // graph (the stage self-guards otherwise). An app where users stay signed in + // across ALL pages therefore declares BOTH here; a JWT/PAT-only API needs + // neither (token layers run before any module loads). + ->withEssentialModules(EntryHelpers::projectEssentials($projectRoot)) + // Compile-only. Returns the Kernel to the entry point, which materializes it // on the first http()/cli() call. ->build(); diff --git a/tools/src/templates/app/bootstrap/kernel-autoload.php b/tools/src/templates/app/bootstrap/kernel-autoload.php index 3f10307..3b95735 100644 --- a/tools/src/templates/app/bootstrap/kernel-autoload.php +++ b/tools/src/templates/app/bootstrap/kernel-autoload.php @@ -39,7 +39,8 @@ * `composer require` the kernel * locally, this alone is enough and * the steps below are skipped. - * 2. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point + * 2. $HKM_KERNEL_HOME/vendor/autoload.php — the installed kernel. + * 2b. $PSP_GLOBAL_AUTOLOAD — explicit override env var. Point * it at any vendor/autoload.php * (e.g. the monorepo's) to reuse a * specific kernel + its plugins. @@ -105,20 +106,41 @@ function psp_require_kernel_autoload(): void $candidates[] = $explicit; } - // (3) Composer's configured home directory, if COMPOSER_HOME is set. + // (3) The installed kernel, via HKM_KERNEL_HOME. + // + // This is how `hkm` installs itself — a system install under + // /opt/hkm-kernel, or a user install under ~/.local/share/hkm/kernel — + // and without it that kernel is invisible to PHP. `hkm run` papered + // over the gap by exporting PSP_GLOBAL_AUTOLOAD for its child, so the + // dev server worked and NOTHING else did: the same project served by + // nginx/PHP-FPM, or a worker started by systemd, or a plain + // `php app/cli/run.php`, died on "Could not load the global kernel + // autoload" with a correctly installed kernel sitting on disk. + $kernelHome = getenv('HKM_KERNEL_HOME'); + if (is_string($kernelHome) && $kernelHome !== '') { + $candidates[] = rtrim($kernelHome, '/\\') . '/vendor/autoload.php'; + } + + // (4) Composer's configured home directory, if COMPOSER_HOME is set. $composerHome = getenv('COMPOSER_HOME'); if (is_string($composerHome) && $composerHome !== '') { $candidates[] = rtrim($composerHome, '/\\') . '/vendor/autoload.php'; } - // (4)+(5) Default global Composer homes on Linux/macOS. + // (5)+(6) Default global Composer homes on Linux/macOS, plus the + // standard `hkm upgrade --user` install path — the one place a kernel + // lands when the operator has no root and never exported anything. $home = getenv('HOME'); if (is_string($home) && $home !== '') { $home = rtrim($home, '/\\'); $candidates[] = $home . '/.config/composer/vendor/autoload.php'; // current default $candidates[] = $home . '/.composer/vendor/autoload.php'; // legacy default + $candidates[] = $home . '/.local/share/hkm/kernel/vendor/autoload.php'; } + // (7) The system install path used by the .deb / install.sh. + $candidates[] = '/opt/hkm-kernel/vendor/autoload.php'; + // Try each candidate; the first one that makes the kernel class // resolvable wins and we return immediately. foreach ($candidates as $autoload) { diff --git a/tools/src/templates/app/public/index.php b/tools/src/templates/app/public/index.php index 39225c8..b70739b 100644 --- a/tools/src/templates/app/public/index.php +++ b/tools/src/templates/app/public/index.php @@ -46,7 +46,14 @@ // attribute (never via a global — coroutine/Swoole safe). $request = Request::capture(); if (isset($domain) && $domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // The FACE (admin/api/project/public) and the HOST let a route declare + // where it exists. Both come from the host DomainResolver already + // VALIDATED against projects.json — never the raw Host header, which + // the client controls and could otherwise pick its own route table. + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } // Run the HTTP pipeline (security → resolve → load → execute) and emit the diff --git a/tools/src/templates/app/swoole/index.php b/tools/src/templates/app/swoole/index.php index dfc7b3f..7688c09 100644 --- a/tools/src/templates/app/swoole/index.php +++ b/tools/src/templates/app/swoole/index.php @@ -135,7 +135,12 @@ $hostHeader = $req->header['host'] ?? null; $domain = EntryHelpers::resolveDomain($rootPath, is_string($hostHeader) ? $hostHeader : null); if ($domain !== null) { - $request = $request->withAttribute('domain', $domain); + $request = $request + ->withAttribute('domain', $domain) + // Face + host come from the VALIDATED host (see the FPM entry point + // for why the raw Host header must never select a route table). + ->withAttribute('route_face', $domain->type->value) + ->withAttribute('route_host', $domain->host); } $response = $kernel->http()->handle($request); diff --git a/tools/src/templates/plugin/migration_alter.php b/tools/src/templates/plugin/migration_alter.php new file mode 100644 index 0000000..02ec26d --- /dev/null +++ b/tools/src/templates/plugin/migration_alter.php @@ -0,0 +1,35 @@ +table('{{LOWER}}', static function ($t) { + // $t->string('widget_id', 64)->nullable(); + // $t->index('widget_id'); + }); + } + + public function down(SchemaBuilderInterface $schema): void + { + $schema->table('{{LOWER}}', static function ($t) { + // $t->dropColumn('widget_id'); + }); + } +}; diff --git a/tools/src/templates/simple/app/bootstrap/app.php b/tools/src/templates/simple/app/bootstrap/app.php new file mode 100644 index 0000000..83b667c --- /dev/null +++ b/tools/src/templates/simple/app/bootstrap/app.php @@ -0,0 +1,216 @@ +http()->handle(...)` for web, `$kernel->cli()->run(...)` for the + * terminal. + * + * ----------------------------------------------------------------------------- + * WHY THIS ONE IS EMPTY + * ----------------------------------------------------------------------------- + * No plugins are enabled. Not "none yet" — none, deliberately. + * + * The framework loads only what a request actually needs, so a plugin you have + * not enabled costs nothing at runtime. It does cost something everywhere else: + * a download, a directory, a line of wiring, a version to keep current, and one + * more thing to understand before you can read your own bootstrap. Starting at + * zero means everything present here is something you asked for. + * + * Add one when a requirement arrives, not in case it does: + * + * hkm plugins install database # DatabasePort, migrations + * hkm plugins install view # PHP templates + * hkm plugins install auth # login, tokens, sessions + * + * `hkm plugins install` fetches the plugin AND the plugins it depends on, wires + * them into this file in dependency order, and publishes their config and + * migrations. `hkm plugins list` shows what is enabled; `hkm plugins domains` + * shows which plugin provides a capability you are looking for. + * + * The full starter (`hkm new `, without --simple) comes with a working + * database, session, cookie, cache, view and validation stack already wired. + * + * ----------------------------------------------------------------------------- + * BOOT ORDER (top to bottom — the order matters) + * ----------------------------------------------------------------------------- + * 1. autoload find the kernel, register the class loaders + * 2. environment load the .env cascade BEFORE anything reads config + * 3. error net catch failures that happen before the kernel is live + * 4. kernel declare paths, routes, security, modules + * 5. build compile manifests and hand the kernel back + */ + +// ----------------------------------------------------------------------------- +// STEP 0 — AUTOLOAD +// kernel-autoload.php only DEFINES the resolver; calling it is what actually +// registers the kernel's class loaders. Requiring the file and forgetting the +// call leaves every framework class undefined, and the failure surfaces on the +// first one used rather than here. +// ----------------------------------------------------------------------------- +if (!function_exists('psp_require_kernel_autoload') || !function_exists('psp_kernel_home')) { + require_once __DIR__ . '/kernel-autoload.php'; +} +psp_require_kernel_autoload(); + +use AlfacodeTeam\PhpServicePlatform\Kernel\Kernel; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\CachePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Ports\DatabasePort; +use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer; + +use Project\Bootstrap\EntryHelpers; +use Project\Infrastructure\FileCache; +use Project\Infrastructure\LazyDatabasePort; +use Project\Infrastructure\PdoDatabase; +use Project\Bootstrap\Environment\ErrorGuard; +use Project\Bootstrap\Environment\LoadEnvironment; + +// ----------------------------------------------------------------------------- +// STEP 1 — PATHS +// Flat layout: the scaffolded directory IS the project, so this file's +// grandparent (bootstrap → app → root) is the project root. +// ----------------------------------------------------------------------------- +$projectRoot = dirname(__DIR__, 2); + +// ----------------------------------------------------------------------------- +// STEP 2 — DOMAIN RESOLUTION +// Turn the request's Host header into a DomainContext (which project face is +// being served, and its features). Null under CLI and workers — no Host header +// there, which is expected and handled downstream. +// ----------------------------------------------------------------------------- +$domain = EntryHelpers::resolveDomain($projectRoot, $_SERVER['HTTP_HOST'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 3 — ENVIRONMENT +// Load .env before anything reads configuration. Real process environment +// always wins, so server config is never clobbered by a file. +// +// Values land in $_ENV/$_SERVER and NOT in putenv(), so read them with the +// env() helper — getenv() will not see them. +// ----------------------------------------------------------------------------- +LoadEnvironment::load($projectRoot, $domain, $_SERVER['argv'] ?? null); + +// ----------------------------------------------------------------------------- +// STEP 4 — PRE-KERNEL ERROR NET +// The outer safety net, for failures the kernel's own error pipeline cannot +// catch because it is not running yet: parse errors, fatals, out-of-memory. +// Writes to the same log the kernel uses, so everything lands in one file. +// ----------------------------------------------------------------------------- +ErrorGuard::install($projectRoot . '/var/logs/errors.log'); + +// ----------------------------------------------------------------------------- +// STEP 5 — THE KERNEL +// ----------------------------------------------------------------------------- +return Kernel::configure() + + // Where things live. Flat layout, so both are the project root. + ->withBasePath($projectRoot) + ->withProjectPath($projectRoot) + + // ------------------------------------------------------------------------- + // PORTS + // ------------------------------------------------------------------------- + // The kernel requires a DatabasePort and a CachePort to be bound before it + // will boot. These two are the kernel's OWN implementations — no plugin + // involved — so an empty project starts and serves immediately. + // + // Both are deliberately modest, and both are meant to be replaced: + // + // hkm plugins install database // pooled multi-driver adapter + // hkm plugins install redis-cache // Redis CachePort + QueuePort + // + // Installing either one rewrites the binding below to use it. + ->withPorts([ + // Lazy: the closure runs on FIRST USE, not at boot. A project with no + // database configured therefore boots and serves normally, and only a + // request that actually touches the database pays for a connection — + // or fails, which is the honest moment to find out DB_DSN is unset. + DatabasePort::class => new LazyDatabasePort( + static fn (): PdoDatabase => new PdoDatabase( + env('DB_DSN', 'sqlite:' . $projectRoot . '/var/database.sqlite'), + env('DB_USERNAME'), + env('DB_PASSWORD'), + ), + ), + + // File-backed, so a cached value survives between requests under + // PHP-FPM (an in-memory cache would not — each request is a new + // process, and every read would miss). + CachePort::class => new FileCache($projectRoot . '/var/cache/data'), + ]) + + // Routes come from proj.json — never from PHP. Declaring them as data is + // what lets the kernel compile a route manifest at build time and resolve a + // request without loading a single module. + ->withRoutes(EntryHelpers::projectRoutes($projectRoot)) + // Route GROUPS from proj.json: a prefix / filters / requires / name + // prefix / SITE stated once for every route inside the group, and + // expanded into flat routes at boot. `site` is part of the route key, + // so one project can answer `GET /` differently per group of hosts. + ->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) + // The hosts this project serves. A route grouped under a domain that is + // not in proj.json "domains" fails the boot — nothing could ever reach it. + ->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) + + // A project can also switch OFF a route a plugin declares, without forking + // the plugin: proj.json "routePolicy": { "disable": ["GET /register"] }. + ->withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot)) + + ->withSecurity([ + // The only security layer the kernel ships: stateless HMAC-signed CSRF + // tokens. Nothing is stored and no cookie value is trusted as the + // token, so cookie injection cannot bypass it. + // + // The secret defaults to APP_KEY. An EMPTY APP_KEY fails closed — every + // state-changing request is denied — so set one before serving traffic: + // hkm key:generate + new CsrfTokenLayer( + headerName: 'X-CSRF-Token', + formField: '_csrf_token', + lifetime: 43200, // 12 hours, in seconds + // Paths that never carry a browser session; APIs authenticate with + // a token instead, for which CSRF is meaningless. + exemptPaths: ['/api'], + ), + + // Authentication is NOT here. The kernel ships no token validator on + // purpose — add the Auth plugin and its layers when you need accounts: + // hkm plugins install auth + ]) + + // ------------------------------------------------------------------------- + // MODULES + // ------------------------------------------------------------------------- + // Empty, and that is the point of --simple. `hkm plugins install ` + // adds entries here for you, in dependency order, with a comment saying + // what each one solves. + // + // A module listed here is loaded ON DEMAND: only when a route being served + // needs it. Listing one costs nothing until something asks for it. + ->withModules([ + // + ]) + + // ------------------------------------------------------------------------- + // ESSENTIAL MODULES + // ------------------------------------------------------------------------- + // Registered into EVERY request, needed or not. Reserve this for + // cross-cutting request-scoped infrastructure (sessions, cookies) that + // cannot be an app-lifetime port — and keep the list short, because each + // entry and its whole dependency graph registers on every single request. + // + // Read from proj.json "essentials": [...], so which plugins are global is a + // deployment decision rather than a code edit. + ->withEssentialModules(EntryHelpers::projectEssentials($projectRoot)) + + // Compile-only: this validates config and compiles the manifests. The + // entry point materializes the kernel on its first http()/cli() call. + ->build(); diff --git a/tools/src/tests.zig b/tools/src/tests.zig new file mode 100644 index 0000000..bb2bf66 --- /dev/null +++ b/tools/src/tests.zig @@ -0,0 +1,65 @@ +//! Test aggregator — the single root the `test` step compiles. +//! +//! Zig collects tests only from files it actually analyses, and analysis is +//! lazy: a file imported but whose declarations are never referenced along a +//! compiled path contributes NOTHING, tests included. Pointing the test step at +//! `main.zig` therefore ran whichever tests the command graph happened to drag +//! in and silently skipped the rest — nine of them, spread across +//! plugin_store, plugin_domains and plugin_bootstrap, among them the checks +//! guarding fork/version collisions in the plugin cache and the "a commented-out +//! provider is not enabled" rule. +//! +//! A test that never runs is worse than no test: it reports safety it is not +//! providing. Referencing every file here forces each one to be analysed, so a +//! new `test "..."` block runs the moment it is written. +//! +//! Generated from `find src -name '*.zig'`. Adding a source file means adding a +//! line here BY HAND; nothing enforces it, because the enforcement would need a +//! directory walk and every Zig filesystem API that could do it differs between +//! the pinned toolchain and the one likely to be installed. Until that is +//! settled, the check is: +//! +//! find src -name '*.zig' | sed 's|^src/||' | grep -vE '^(main|tests)\.zig$' \ +//! | while read f; do grep -q "\"$f\"" src/tests.zig || echo "MISSING: $f"; done + +const std = @import("std"); + +test { + _ = @import("commands/cli.zig"); + _ = @import("commands/discover.zig"); + _ = @import("commands/doctor.zig"); + _ = @import("commands/list.zig"); + _ = @import("commands/module.zig"); + _ = @import("commands/new.zig"); + _ = @import("commands/plugins.zig"); + _ = @import("commands/run.zig"); + _ = @import("commands/ui.zig"); + _ = @import("commands/update.zig"); + _ = @import("commands/upgrade.zig"); + _ = @import("config.zig"); + _ = @import("constants.zig"); + _ = @import("lib/banner.zig"); + _ = @import("lib/inspector/dashboard.zig"); + _ = @import("lib/inspector/meminspector.zig"); + _ = @import("lib/inspector/tracked.zig"); + _ = @import("lib/kernel.zig"); + _ = @import("lib/memory.zig"); + _ = @import("lib/plugin_assets.zig"); + _ = @import("lib/plugin_bootstrap.zig"); + _ = @import("lib/plugin_deps.zig"); + _ = @import("lib/plugin_domains.zig"); + _ = @import("lib/plugin_git.zig"); + _ = @import("lib/plugin_install.zig"); + _ = @import("lib/plugin_lock.zig"); + _ = @import("lib/plugin_registry.zig"); + _ = @import("lib/plugin_sources.zig"); + _ = @import("lib/plugin_store.zig"); + _ = @import("lib/plugin_ui.zig"); + _ = @import("lib/prompt.zig"); + _ = @import("lib/registry.zig"); + _ = @import("lib/semver.zig"); + _ = @import("lib/services.zig"); + _ = @import("lib/userconfig.zig"); + _ = @import("lib/util.zig"); + _ = @import("stamp.zig"); +} From 04706d95d4742d3ba4ed078074b4292319bda61a Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 02:18:20 +0300 Subject: [PATCH 124/140] chore: release 1.2.0 From 33cc616143d743b54715abb77cb87211c1d17129 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 02:20:05 +0300 Subject: [PATCH 125/140] fix(release): point modules/let-migrate back at its published commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1.2.0 release build could not check out: the submodule pointer referenced cd3f71c, which exists only on a developer machine and was never pushed to AlfaCode-Team/Let-Migrate. fatal: remote error: upload-pack: not our ref cd3f71c… Fetched in submodule path 'modules/let-migrate', but it did not contain it. Restores the pointer to 68f72db, the newest commit the remote actually has. The local checkout is left where it is, so the unpushed work is not lost — push it to Let-Migrate and bump the pointer deliberately when it is ready to ship. --- modules/let-migrate | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/let-migrate b/modules/let-migrate index cd3f71c..68f72db 160000 --- a/modules/let-migrate +++ b/modules/let-migrate @@ -1 +1 @@ -Subproject commit cd3f71c7fd14c5f68d2d9eb0637ed680ee736128 +Subproject commit 68f72dbeb9740a814e7c47c937c955b3f130edda From 50a1d26b5e91d9997b2a56e5b7d40569cfbebc8e Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 02:30:28 +0300 Subject: [PATCH 126/140] feat: add plugin domains and store management - Introduced `plugin_domains.zig` to manage domain resolution for plugins, allowing for better dependency handling based on declared capabilities rather than repository names. - Implemented `plugin_store.zig` to create a global plugin cache, ensuring that plugins are shared across projects and reducing redundant downloads. - Added migration template `migration_alter.php` for altering existing database tables in plugins. - Created a simple application bootstrap file `app.php` to initialize the project with a focus on minimalism and explicit dependency management. - Established a test aggregator `tests.zig` to ensure all relevant tests are executed, improving test coverage and reliability. --- modules/let-migrate | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/let-migrate b/modules/let-migrate index 68f72db..cd3f71c 160000 --- a/modules/let-migrate +++ b/modules/let-migrate @@ -1 +1 @@ -Subproject commit 68f72dbeb9740a814e7c47c937c955b3f130edda +Subproject commit cd3f71c7fd14c5f68d2d9eb0637ed680ee736128 From 7fe1de2df82208f47a3273032de7057871445d54 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 02:32:43 +0300 Subject: [PATCH 127/140] fix(release): drop the version field from the checked-in composer.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `composer validate` is a required check and it failed on 1.2.0: the lock file's content-hash covers the version field, so stamping a literal version dirtied the lock, and Composer warns about the field on a Packagist-published package. The field is absent from the repo BY DESIGN — build.zig says why: the native distribution ships without a .git directory, so a .deb or zip has no tags to derive a version from and needs the marker; but in a checkout "a literal version OVERRIDES the tags, and the two silently drift apart". tools/bundle.sh stamps it at release time, which is how the published v1.2.0 artifacts got their version. --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index ba66d0b..e229b55 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,6 @@ "description": "A modular PHP backend boilerplate built with OpenSwoole and a multi-repository architecture using Git submodules. Designed for scalable, high-performance microservices and service-oriented applications.", "type": "library", "license": "MIT", - "version": "1.2.0", "keywords": [ "framework", "php", From e2ecaae0e02a2f68c62e37b08b8da234130a55dd Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 02:35:20 +0300 Subject: [PATCH 128/140] fix(release): actually pin let-migrate to its published commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix set the gitlink in the index only, leaving the submodule's working tree on the unpushed cd3f71c — and the next commit silently picked it back up, so CI failed again with "not our ref cd3f71c". This moves the working tree too, so index, tree and HEAD agree and it cannot drift back. The unpushed work is preserved as the branch `wip/unpushed-cd3f71c` inside modules/let-migrate. Push it to AlfaCode-Team/Let-Migrate and bump the pointer deliberately when it is ready to ship. --- modules/let-migrate | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/let-migrate b/modules/let-migrate index cd3f71c..68f72db 160000 --- a/modules/let-migrate +++ b/modules/let-migrate @@ -1 +1 @@ -Subproject commit cd3f71c7fd14c5f68d2d9eb0637ed680ee736128 +Subproject commit 68f72dbeb9740a814e7c47c937c955b3f130edda From 546d625bc4562008222800d6ddb0a0d60b8878cc Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 02:59:35 +0300 Subject: [PATCH 129/140] fix(ci,stamp): stop duplicate PR checks and reject a version that corrupts JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the review on #109. DUPLICATE CHECKS php-analysis.yml listed master under BOTH push and pull_request, so every master->main PR ran it twice — the push event for refs/heads/master and the pull_request event for refs/pull/N/merge. The concurrency group is keyed on the ref, so the two never collided, and each PR showed "composer audit", "PHPStan" and "Semgrep" twice. Limited push to main, which is the shape ci.yml already uses (and why PHPUnit and Zig build appeared only once). Master is still analysed — through the PR. COMPOSER.JSON CORRUPTION composerValid() discarded everything after '+' without looking at it, so `1.1.0+"` validated; stamp() then writes the version RAW between JSON quotes and produced an unparseable composer.json: "version": "1.1.0+"", Build metadata is now validated as semver defines it — dot-separated [0-9A-Za-z-] identifiers — and stamp() refuses outright any version carrying a quote, backslash or control character. Regression tests cover both. SILENT DIAGNOSTICS - A version longer than the 256-byte message buffer made bufPrint fail, and the error path returned WITHOUT printing anything, so the marker was skipped with no diagnostic at all. Falls back to a fixed message. - `hkm new` swallowed recordInLock failures while still counting the plugin as installed, so the command reported success with a lock that did not list it. It now names the plugin and how to repair the lock. --- .github/workflows/php-analysis.yml | 8 ++- tools/src/commands/new.zig | 31 +++++++++- tools/src/stamp.zig | 94 +++++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/.github/workflows/php-analysis.yml b/.github/workflows/php-analysis.yml index 5666fb9..fed0461 100644 --- a/.github/workflows/php-analysis.yml +++ b/.github/workflows/php-analysis.yml @@ -7,8 +7,14 @@ name: PHP Analysis # • PHPStan — type/static analysis (non-blocking until a baseline lands) "on": + # `push` is limited to main — the same shape ci.yml uses. Listing master here + # too made every master->main PR run this workflow TWICE: the push event fires + # for refs/heads/master and the pull_request event for refs/pull/N/merge, and + # because the concurrency group is keyed on the ref, the two never collide. + # That is where the duplicate "composer audit", "PHPStan" and "Semgrep" checks + # on a PR came from. Changes to master still get analysed — through the PR. push: - branches: [main, master] + branches: [main] pull_request: branches: [main, master] schedule: diff --git a/tools/src/commands/new.zig b/tools/src/commands/new.zig index c6b3e8f..22162ba 100644 --- a/tools/src/commands/new.zig +++ b/tools/src/commands/new.zig @@ -33,6 +33,7 @@ const plugin_assets = @import("../lib/plugin_assets.zig"); const plugins_cmd = @import("plugins.zig"); const plugin_boot = @import("../lib/plugin_bootstrap.zig"); const installer = @import("../lib/plugin_install.zig"); +const lockfile = @import("../lib/plugin_lock.zig"); const Dir = std.Io.Dir; const Io = std.Io; @@ -588,6 +589,28 @@ fn domainsJson(allocator: std.mem.Allocator, domains: []const []const u8) ![]con /// behind the template would reproduce exactly the missing-class failure this /// exists to prevent. /// Returns the number of plugins that could NOT be installed. +/// Record an installed plugin in plugins.lock.json, naming it if that fails. +/// +/// The install itself succeeded, so this is not fatal — but a silent failure +/// leaves the lock disagreeing with what is on disk, and the user with no idea +/// which plugin to re-add. +fn recordOrWarn( + allocator: std.mem.Allocator, + io: Io, + projectRoot: []const u8, + name: []const u8, + entry: lockfile.Entry, +) void { + installer.recordInLock(allocator, io, projectRoot, entry) catch { + const msg = std.fmt.allocPrint( + allocator, + "{s}: installed, but could not be recorded in plugins.lock.json — run `hkm plugins add {s}` to repair the lock.", + .{ name, name }, + ) catch return; + prompt.warn(msg); + }; +} + fn installBootstrapPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, opts: Options) !usize { const bootstrap = try util.join(allocator, opts.path, "app/bootstrap/app.php"); const source = Dir.cwd().readFileAlloc(io, bootstrap, allocator, .limited(4 * 1024 * 1024)) catch return 0; @@ -628,9 +651,13 @@ fn installBootstrapPlugins(allocator: std.mem.Allocator, io: Io, env: *EnvMap, o .installed, .up_to_date, .linked, .updated => { ok += 1; _ = installer.report(allocator, e.name, outcome, false) catch {}; + // A lockfile write that fails is NOT a successful install: + // swallowing it left the command reporting success while + // plugins.lock.json did not record the plugin, so the next + // `hkm plugins` run cannot tell it is already there. switch (outcome) { - .installed, .up_to_date, .linked => |entry| installer.recordInLock(allocator, io, opts.path, entry) catch {}, - .updated => |u| installer.recordInLock(allocator, io, opts.path, u.to) catch {}, + .installed, .up_to_date, .linked => |entry| recordOrWarn(allocator, io, opts.path, e.name, entry), + .updated => |u| recordOrWarn(allocator, io, opts.path, e.name, u.to), .refused => {}, } }, diff --git a/tools/src/stamp.zig b/tools/src/stamp.zig index b68ff2c..cae4993 100644 --- a/tools/src/stamp.zig +++ b/tools/src/stamp.zig @@ -78,13 +78,18 @@ pub fn main(init: std.process.Init.Minimal) !void { // where it matters. Skip quietly for that shape; warn for anything else. if (isDescribeVersion(version)) return; + // A version longer than the buffer would make bufPrint fail, and + // returning there skipped the marker with NO diagnostic at all — the + // silent failure this warning exists to prevent. Fall back to a fixed + // message so every rejected version is reported. var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &buf, "stamp: '{s}' is not a valid Composer version — leaving composer.json alone.\n" ++ " (Composer accepts 1.2.3, 1.2.3-dev, 1.2.3-beta.4, 1.2.3-RC1; a 'dev' suffix takes no number.)\n", .{version}, - ) catch return; + ) catch + "stamp: the requested version is not valid for Composer — leaving composer.json alone.\n"; std.Io.File.stderr().writeStreamingAll(io, msg) catch {}; return; } @@ -97,6 +102,31 @@ pub fn main(init: std.process.Init.Minimal) !void { try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = updated }); } +/// Semver build metadata: dot-separated identifiers of [0-9A-Za-z-], each +/// non-empty. Deliberately strict — this string is written verbatim into JSON. +fn validMetadata(meta: []const u8) bool { + if (meta.len == 0) return false; + + var it = std.mem.splitScalar(u8, meta, '.'); + while (it.next()) |ident| { + if (ident.len == 0) return false; + for (ident) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-') return false; + } + } + return true; +} + +/// Nothing that would break out of a JSON string, whatever validation decided. +/// composerValid() is the gate; this is the seatbelt, because the cost of being +/// wrong is a composer.json no install can parse. +fn jsonSafe(v: []const u8) bool { + for (v) |c| { + if (c == '"' or c == '\\' or c < 0x20 or c == 0x7f) return false; + } + return true; +} + /// Does this look like `git describe` output — "--g"? /// /// Matched on the trailing "--g" only, so a real pre-release @@ -131,8 +161,15 @@ fn composerValid(v: []const u8) bool { if (s_.len == 0) return false; if (s_[0] == 'v' or s_[0] == 'V') s_ = s_[1..]; - // Build metadata is always allowed; ignore it. - if (std.mem.indexOfScalar(u8, s_, '+')) |i| s_ = s_[0..i]; + // Build metadata is allowed, but it still has to BE metadata. Discarding it + // unchecked let anything through — `composerValid("1.1.0+\"")` returned + // true, and stamp() writes the version raw between JSON quotes, so that one + // input produced an unparseable composer.json. Semver defines metadata as + // dot-separated [0-9A-Za-z-] identifiers; anything else is rejected. + if (std.mem.indexOfScalar(u8, s_, '+')) |i| { + if (!validMetadata(s_[i + 1 ..])) return false; + s_ = s_[0..i]; + } if (s_.len == 0) return false; // 1-4 numeric components separated by '.' or '-'. @@ -189,6 +226,10 @@ fn composerValid(v: []const u8) bool { /// /// Exposed for testing. pub fn stamp(allocator: std.mem.Allocator, source: []const u8, version: []const u8) !?[]const u8 { + // The version is written raw between JSON quotes below, so refuse outright + // anything that could terminate the string or embed a control character. + if (!jsonSafe(version)) return null; + if (findVersionValue(source)) |span| { if (std.mem.eql(u8, source[span.start..span.end], version)) return null; // no-op var out: std.ArrayList(u8) = .empty; @@ -373,6 +414,53 @@ test "a version ending in 'v' keeps its last character" { try std.testing.expect(!composerValid("1.1.0-de")); } +test "build metadata is validated, not waved through" { + // The bug: metadata was discarded unchecked, so this returned true — and + // stamp() writes the version raw between JSON quotes, producing a + // composer.json no install can parse. + try std.testing.expect(!composerValid("1.1.0+\"")); + try std.testing.expect(!composerValid("1.1.0+a\\b")); + try std.testing.expect(!composerValid("1.1.0+a\nb")); + try std.testing.expect(!composerValid("1.1.0+")); // empty metadata + try std.testing.expect(!composerValid("1.1.0+a..b")); // empty identifier + try std.testing.expect(!composerValid("1.1.0+a b")); + + // …while real metadata still passes. + try std.testing.expect(composerValid("1.1.0+build.1")); + try std.testing.expect(composerValid("1.1.0+20260812")); + try std.testing.expect(composerValid("1.1.0+g29dccfb")); + try std.testing.expect(composerValid("1.1.0-beta.1+exp.sha.5114f85")); +} + +test "stamp refuses a version that could break out of the JSON string" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "acme/pkg", + \\ "type": "library" + \\} + ; + for ([_][]const u8{ "1.0.0+\"", "1.0.0\\", "1.0.0\n", "1.0.0\x7f" }) |bad| { + try std.testing.expect((try stamp(a, src, bad)) == null); + } +} + +test "a stamped composer.json is still parseable JSON" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "acme/pkg", + \\ "type": "library" + \\} + ; + const out = (try stamp(a, src, "1.2.0")) orelse return error.ExpectedOutput; + defer a.free(out); + + const parsed = try std.json.parseFromSlice(std.json.Value, a, out, .{}); + defer parsed.deinit(); + try std.testing.expectEqualStrings("1.2.0", parsed.value.object.get("version").?.string); +} + test "a git describe version is recognised so dev builds stay quiet" { try std.testing.expect(isDescribeVersion("1.1.0-dev.2-12-g29dccfb")); try std.testing.expect(isDescribeVersion("1.0.21-138-gbdbbf34")); From fa4c9d2359df93055f5c683a857aca6337eeba4c Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 11:25:40 +0300 Subject: [PATCH 130/140] chore: update subproject commits for http and php-io-cli modules --- modules/http | 2 +- modules/php-io-cli | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/http b/modules/http index 5bc998a..e60823d 160000 --- a/modules/http +++ b/modules/http @@ -1 +1 @@ -Subproject commit 5bc998ac4a9575a560a027073b8e2792bfff27ae +Subproject commit e60823d6479882fac66588ffbf73da96cd5210ee diff --git a/modules/php-io-cli b/modules/php-io-cli index 53620ec..6558290 160000 --- a/modules/php-io-cli +++ b/modules/php-io-cli @@ -1 +1 @@ -Subproject commit 53620ec587ac8cf29ce82f239e072f566d73c79c +Subproject commit 655829006ca95ff25841b025a72d398ecbe2f273 From 6c7de55a2f29cd35380d7e6fd774667c7adf830f Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 22:39:52 +0300 Subject: [PATCH 131/140] feat(routing): accept a LIST of domains at every level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `domain` / `subdomain` previously took a single host, at the route and group level, while the module-wide `routeDomain` took one too. A project serving several hosts therefore could not pin a group to "these three and not that one" — the only way to express it was to duplicate the whole group per host, which is how a route ends up on a host nobody meant to serve it on. All three levels — module-wide, group, route — now take either a string or a list, and behave identically. One of them quietly refusing a list is the kind of inconsistency only ever discovered by it not working. The domain stays part of the route KEY, so one project still answers GET / differently per host, and a route grouped under a host the project does not serve is still rejected at boot rather than silently unreachable. --- .../Boot/Stages/CompileRouteManifestStage.php | 256 ++++++++++++++++-- tests/Unit/Kernel/Boot/RouteGroupTest.php | 236 ++++++++++++++++ 2 files changed, 465 insertions(+), 27 deletions(-) diff --git a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php index 0d08264..0fa1c6d 100644 --- a/src/Kernel/Boot/Stages/CompileRouteManifestStage.php +++ b/src/Kernel/Boot/Stages/CompileRouteManifestStage.php @@ -255,17 +255,26 @@ public function run(): void */ private function flatten(array $source, string $owner): array { - return $this->flattenInto($source, [ + $scope = [ 'prefix' => $this->normalizePrefix($source['routePrefix'] ?? '', $owner), 'name' => $this->stringOrEmpty($source['routeName'] ?? '', $owner, 'routeName'), 'filters' => $this->normalizeFilters($source['routeFilters'] ?? [], $owner), 'requires' => $this->normalizeRequires($source['routeRequires'] ?? []), - 'domain' => $this->checkedDomain( - $this->normalizeDomain($source['routeDomain'] ?? $source['routeSubdomain'] ?? ''), - $owner, - ), + 'domain' => '', 'faces' => $this->normalizeFaces($source['routeFaces'] ?? []), - ], $owner, 0); + ]; + + // The module-wide domain takes a list too, so the three levels that can + // name a host — module-wide, group, route — all behave the same way. + // One of them quietly refusing a list is the kind of inconsistency that + // is only ever discovered by it not working. + $flat = []; + foreach ($this->domainsFor($source, $scope, $owner, 'routeDomain', 'routeSubdomain') as $domain) { + $scope['domain'] = $domain; + $flat = [...$flat, ...$this->flattenInto($source, $scope, $owner, 0)]; + } + + return $flat; } /** @@ -298,7 +307,7 @@ private function flattenInto(array $source, array $inherited, string $owner, int $name = $this->stringOrEmpty($route['name'] ?? '', $owner, 'name'); - $flat[] = [ + $entry = [ 'method' => strtoupper(trim((string) $route['method'])), 'path' => $path, 'handler' => (string) $route['handler'], @@ -313,14 +322,37 @@ private function flattenInto(array $source, array $inherited, string $owner, int $inherited['requires'], $this->normalizeRequires($route['requires'] ?? []), ), - 'domain' => isset($route['domain']) || isset($route['subdomain']) - ? $this->checkedDomain( - $this->normalizeDomain($route['domain'] ?? $route['subdomain']), - "Route in {$owner}", - ) - : $inherited['domain'], + 'domain' => $inherited['domain'], 'faces' => $this->normalizeFaces($route['faces'] ?? []) ?: $inherited['faces'], ]; + + $domains = $this->domainsFor($route, $inherited, "Route in {$owner}"); + + // A NAMED route on several domains would claim one name several + // times. Names are a flat, application-wide namespace on purpose — + // UrlGenerator holds no request state, so it cannot pick a host — + // and the duplicate-name guard would otherwise report this later + // without explaining the cause. + if ($name !== '' && count($domains) > 1) { + throw new BootException(sprintf( + 'Route [%s] in %s names itself [%s] while declaring %d domains. ' + . 'Route names are one flat namespace, so one name cannot mean a ' + . 'different URL per host. Give each domain its own entry with a ' + . 'distinct name, or drop the name.', + $path, + $owner, + $inherited['name'] . $name, + count($domains), + )); + } + + // One flat route per domain. A single string yields one, exactly as + // before; a list yields one copy per host, each with its own route + // key, which is what makes them independently overridable. + foreach ($domains as $domain) { + $entry['domain'] = $domain; + $flat[] = $entry; + } } foreach ($source['groups'] ?? [] as $group) { @@ -328,27 +360,32 @@ private function flattenInto(array $source, array $inherited, string $owner, int throw new BootException("Invalid route group in {$owner} - a group must be an object."); } - $flat = [...$flat, ...$this->flattenInto($group, [ + $context = "Route group in {$owner}"; + + $scope = [ 'prefix' => $inherited['prefix'] - . $this->normalizePrefix($group['prefix'] ?? '', "Route group in {$owner}"), + . $this->normalizePrefix($group['prefix'] ?? '', $context), 'name' => $inherited['name'] . $this->stringOrEmpty($group['name'] ?? '', $owner, 'group name'), 'filters' => $this->mergeFilters( $inherited['filters'], - $this->normalizeFilters($group['filters'] ?? [], "Route group in {$owner}"), + $this->normalizeFilters($group['filters'] ?? [], $context), ), 'requires' => $this->mergeRequires( $inherited['requires'], $this->normalizeRequires($group['requires'] ?? []), ), - 'domain' => isset($group['domain']) || isset($group['subdomain']) - ? $this->checkedDomain( - $this->normalizeDomain($group['domain'] ?? $group['subdomain']), - "Route group in {$owner}", - ) - : $inherited['domain'], + 'domain' => $inherited['domain'], 'faces' => $this->normalizeFaces($group['faces'] ?? []) ?: $inherited['faces'], - ], $owner, $depth + 1)]; + ]; + + // Expand the WHOLE subtree once per domain. Nested groups and routes + // inherit the one host they are being expanded for, so a list at any + // level composes with a list at any other. + foreach ($this->domainsFor($group, $inherited, $context) as $domain) { + $scope['domain'] = $domain; + $flat = [...$flat, ...$this->flattenInto($group, $scope, $owner, $depth + 1)]; + } } return $flat; @@ -382,12 +419,177 @@ private function normalizeDomain(mixed $domain): string : $domain; } - /** Validate a normalised domain and return it, so it composes in an expression. */ - private function checkedDomain(string $domain, string $context): string + /** + * The domains a route or group answers on — ONE OR MORE. + * + * `"domain"` and `"subdomain"` accept either a single string or a LIST, so + * one group can serve several hosts without being written out N times: + * + * "domain": "shop.example.com" + * "domain": ["shop.example.com", "shop.example.co.uk", "*.tenant.example.com"] + * "subdomain": ["admin", "staff"] + * + * Each entry is grouped verbatim and validated independently, exactly as a + * single value is, and the caller emits one copy of the route per entry. + * Groups already expand at boot into flat routes, so this costs nothing at + * request time — it is the same expansion with a wider fan-out. + * + * A non-string, non-list value is a BOOT FAILURE. It used to fall through + * `is_string()` to '', which silently turned "these routes belong to these + * two hosts" into "these routes are global, on every host" — the widest + * possible outcome, arrived at by accident, with nothing logged. + * + * @return list normalised domains, de-duplicated. `['']` means the + * shared (every-domain) table. + */ + private function normalizeDomainList(mixed $domain, string $context): array { - $this->validateDomain($domain, $context); + if (is_string($domain)) { + return [$this->normalizeDomain($domain)]; + } + + if (!is_array($domain)) { + throw new BootException(sprintf( + '%s declares a domain of type [%s]. Use a string ("shop.example.com") ' + . 'or a list of strings (["a.example.com", "b.example.com"]).', + $context, + get_debug_type($domain), + )); + } + + if ($domain === []) { + throw new BootException(sprintf( + '%s declares an empty domain list. Remove the key to serve every ' + . 'domain, or name at least one host.', + $context, + )); + } + + $out = []; + foreach ($domain as $entry) { + if (!is_string($entry)) { + throw new BootException(sprintf( + '%s has a non-string entry of type [%s] in its domain list.', + $context, + get_debug_type($entry), + )); + } + + $normalized = $this->normalizeDomain($entry); + if ($normalized === '') { + throw new BootException(sprintf( + '%s lists [%s] as a domain, which is not usable as one. A domain ' + . 'may not be blank or contain a space or an "%s".', + $context, + $entry, + RouteIndex::DOMAIN_SEPARATOR, + )); + } + + // A repeat would compile the same route twice under one key and trip + // the duplicate-route guard — reporting a conflict the author would + // have to work backwards to recognise as their own copy-paste. + if (!in_array($normalized, $out, true)) { + $out[] = $normalized; + } + } + + return $out; + } + + /** + * The domains a route, group or module compiles under — always at least one. + * + * Declaring neither key inherits the enclosing scope, which is what makes an + * ungrouped route global and a nested group stay on its parent's host. + * + * The key names are parameters because the module-wide form spells them + * `routeDomain`/`routeSubdomain` while routes and groups use + * `domain`/`subdomain` — the same rule, read from different keys. + * + * @param array $source the route, group or module object + * @param array $inherited the enclosing scope + * @return list + */ + private function domainsFor( + array $source, + array $inherited, + string $context, + string $domainKey = 'domain', + string $subdomainKey = 'subdomain', + ): array { + $hasDomain = isset($source[$domainKey]); + $hasSubdomain = isset($source[$subdomainKey]); + + if (!$hasDomain && !$hasSubdomain) { + return [(string) $inherited['domain']]; + } + + $parents = $hasDomain + ? $this->normalizeDomainList($source[$domainKey], $context) + : []; + $labels = $hasSubdomain + ? $this->normalizeDomainList($source[$subdomainKey], $context) + : []; + + // A declared `domain` is BOTH a host in its own right AND the parent that + // `subdomain` attaches to. + // + // { "domain": "hkm.local", "subdomain": ["api", "auth"] } + // → hkm.local, api.hkm.local, auth.hkm.local + // + // Without this, those labels compiled BARE — and a bare label spans every + // domain by design, so the group also answered on api.somebody-else.com. + // Reading "api under hkm.local" and getting "api under anything" is not a + // difference anyone spots until it is exploited. + // + // A label with NO domain to attach to keeps the global meaning, because + // there is nothing for it to be relative to — that is what makes an + // `admin` panel appear on every brand. + if ($parents === []) { + foreach ($labels as $label) { + $this->validateDomain($label, $context); + } + + return $labels; + } + + foreach ($parents as $parent) { + $this->validateDomain($parent, $context); + } + + // No labels to attach: the domains stand alone. Returning here also keeps + // the wildcard check below from firing on `{"domain": "*.example.com"}`, + // which composes nothing and is entirely valid on its own. + if ($labels === []) { + return $parents; + } + + $domains = $parents; + foreach ($parents as $parent) { + if (str_starts_with($parent, '*.')) { + throw new BootException(sprintf( + '%s attaches subdomain [%s] to wildcard domain [%s]. A wildcard ' + . 'already covers every label under it, so the two cannot compose. ' + . 'Drop the subdomain, or name the parent host literally.', + $context, + $labels[0], + $parent, + )); + } + + foreach ($labels as $label) { + // The composed host is DERIVED from a parent already validated + // above, and DomainResolver reaches this project by suffix match + // on that same parent — so it needs no registration of its own. + $composed = $label . '.' . $parent; + if (!in_array($composed, $domains, true)) { + $domains[] = $composed; + } + } + } - return $domain; + return $domains; } /** diff --git a/tests/Unit/Kernel/Boot/RouteGroupTest.php b/tests/Unit/Kernel/Boot/RouteGroupTest.php index dbb4ec4..149bd79 100644 --- a/tests/Unit/Kernel/Boot/RouteGroupTest.php +++ b/tests/Unit/Kernel/Boot/RouteGroupTest.php @@ -579,4 +579,240 @@ public function test_a_group_name_prefix_disambiguates_two_domains(): void $this->manifest('route-names.php'), ); } + + // ── A LIST of domains ─────────────────────────────────────────────────── + + public function test_a_group_may_name_several_domains_at_once(): void + { + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'b.test'], + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + + // One route per host, each with its own key — so either can later be + // overridden or disabled without touching the other. + self::assertArrayHasKey('GET@a.test /dash', $this->manifest()); + self::assertArrayHasKey('GET@b.test /dash', $this->manifest()); + + self::assertNotNull($this->matchHost('/dash', 'a.test')); + self::assertNotNull($this->matchHost('/dash', 'b.test')); + self::assertNull($this->matchHost('/dash', 'c.test')); + } + + public function test_a_route_may_name_several_domains_at_once(): void + { + $this->compile(domains: ['a.test', 'b.test'], projectRoutes: [ + ['method' => 'GET', 'path' => '/p', 'handler' => 'A\\A@h', 'domain' => ['a.test', 'b.test']], + ]); + + self::assertArrayHasKey('GET@a.test /p', $this->manifest()); + self::assertArrayHasKey('GET@b.test /p', $this->manifest()); + } + + public function test_a_subdomain_list_answers_on_each_label(): void + { + $this->compile(['groups' => [[ + 'subdomain' => ['admin', 'staff'], + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + + self::assertNotNull($this->matchHost('/ops', 'admin.anything.test')); + self::assertNotNull($this->matchHost('/ops', 'staff.other.test')); + self::assertNull($this->matchHost('/ops', 'public.anything.test')); + } + + public function test_a_nested_group_composes_with_an_outer_list(): void + { + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'b.test'], + 'groups' => [[ + 'prefix' => '/admin', + 'routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\A@h']], + ]], + ]]], domains: ['a.test', 'b.test']); + + self::assertArrayHasKey('GET@a.test /admin/x', $this->manifest()); + self::assertArrayHasKey('GET@b.test /admin/x', $this->manifest()); + } + + public function test_a_repeated_domain_does_not_compile_the_route_twice(): void + { + // Would otherwise trip the duplicate-route guard and report a conflict + // the author has to work backwards to recognise as their own copy-paste. + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'A.TEST', ' a.test '], + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + + self::assertCount(1, $this->manifest()); + } + + public function test_every_domain_in_a_list_is_validated(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/does not serve/'); + + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'not-registered.test'], + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + } + + public function test_a_non_string_domain_fails_the_boot_instead_of_going_global(): void + { + // The regression this guards: a non-string used to fall through + // is_string() to '', which silently turned "these routes belong to this + // host" into "these routes answer on EVERY host" — the widest possible + // outcome, reached by accident, with nothing logged. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/domain of type/'); + + $this->compile(['groups' => [[ + 'domain' => 42, + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + } + + public function test_an_empty_domain_list_fails_the_boot(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/empty domain list/'); + + $this->compile(['groups' => [[ + 'domain' => [], + 'routes' => [['method' => 'GET', 'path' => '/dash', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + } + + public function test_a_named_route_may_not_span_several_domains(): void + { + // Names are one flat namespace; one name cannot mean a different URL + // per host, because UrlGenerator holds no request state. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/one flat namespace/'); + + $this->compile(domains: ['a.test', 'b.test'], projectRoutes: [ + ['method' => 'GET', 'path' => '/p', 'handler' => 'A\\A@h', + 'name' => 'p', 'domain' => ['a.test', 'b.test']], + ]); + } + + // ── domain AND subdomain together (subdomain is RELATIVE to domain) ──── + + public function test_a_subdomain_is_attached_to_the_declared_domain(): void + { + // A declared `domain` is BOTH a host in its own right AND the parent the + // subdomain attaches to. Before, the label compiled BARE — and a bare + // label spans every domain, so this also answered on admin.anyone-else.com. + $this->compile(['groups' => [[ + 'domain' => 'brand.test', + 'subdomain' => 'admin', + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]], domains: ['brand.test']); + + self::assertSame( + ['GET@brand.test /ops', 'GET@admin.brand.test /ops'], + array_keys($this->manifest()), + ); + + self::assertNotNull($this->matchHost('/ops', 'brand.test')); + self::assertNotNull($this->matchHost('/ops', 'admin.brand.test')); + // The decisive one: NOT that label on somebody else's domain. + self::assertNull($this->matchHost('/ops', 'admin.anyone-else.com')); + } + + public function test_every_label_attaches_to_every_domain(): void + { + $this->compile(['groups' => [[ + 'domain' => ['a.test', 'b.test'], + 'subdomain' => ['admin', 'staff'], + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test', 'b.test']); + + foreach ([ + 'GET@a.test /ops', 'GET@b.test /ops', + 'GET@admin.a.test /ops', 'GET@staff.a.test /ops', + 'GET@admin.b.test /ops', 'GET@staff.b.test /ops', + ] as $key) { + self::assertArrayHasKey($key, $this->manifest()); + } + self::assertCount(6, $this->manifest()); + } + + public function test_a_subdomain_with_no_domain_stays_global(): void + { + // Nothing to be relative to, so the documented bare-label meaning holds — + // this is what puts an `admin` panel on every brand. + $this->compile(['groups' => [[ + 'subdomain' => 'admin', + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]]); + + self::assertArrayHasKey('GET@admin /ops', $this->manifest()); + self::assertNotNull($this->matchHost('/ops', 'admin.anyone-else.com')); + } + + public function test_a_composed_host_needs_no_registration_of_its_own(): void + { + // Only the PARENT is in proj.json domains[]. DomainResolver reaches this + // project by suffix match on that parent, so the composed host is + // reachable and validating it separately would just be busywork. + $this->compile(['groups' => [[ + 'domain' => 'hkm.local', + 'subdomain' => ['api', 'auth'], + 'routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\A@h']], + ]]], domains: ['hkm.local']); + + self::assertArrayHasKey('GET@api.hkm.local /x', $this->manifest()); + self::assertArrayHasKey('GET@auth.hkm.local /x', $this->manifest()); + } + + public function test_a_subdomain_may_not_attach_to_a_wildcard(): void + { + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/cannot compose/'); + + $this->compile(['groups' => [[ + 'domain' => '*.hkm.local', + 'subdomain' => 'api', + 'routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\A@h']], + ]]], domains: ['hkm.local']); + } + + public function test_a_wildcard_domain_alone_still_compiles(): void + { + // Regression: the wildcard guard above must not fire when there is no + // subdomain to attach — `{"domain": "*.x"}` composes nothing and is valid. + $this->compile(['groups' => [[ + 'domain' => '*.hkm.local', + 'routes' => [['method' => 'GET', 'path' => '/x', 'handler' => 'A\\A@h']], + ]]], domains: ['hkm.local']); + + self::assertArrayHasKey('GET@*.hkm.local /x', $this->manifest()); + } + + public function test_a_route_may_attach_a_subdomain_to_its_domain(): void + { + $this->compile(domains: ['a.test'], projectRoutes: [ + ['method' => 'GET', 'path' => '/p', 'handler' => 'A\\A@h', + 'domain' => 'a.test', 'subdomain' => 'api'], + ]); + + self::assertArrayHasKey('GET@a.test /p', $this->manifest()); + self::assertArrayHasKey('GET@api.a.test /p', $this->manifest()); + } + + public function test_a_bad_subdomain_is_reported_even_when_domain_is_valid(): void + { + // The subdomain used to be skipped entirely when a domain was present, + // so a mistake in it could not be reported at all. + $this->expectException(BootException::class); + $this->expectExceptionMessageMatches('/domain of type/'); + + $this->compile(['groups' => [[ + 'domain' => 'a.test', + 'subdomain' => 42, + 'routes' => [['method' => 'GET', 'path' => '/ops', 'handler' => 'A\\A@h']], + ]]], domains: ['a.test']); + } } From f5ee7afa866f53875e6cd47048e6410fa664346c Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 22:39:52 +0300 Subject: [PATCH 132/140] feat(plugins): seed a plugin's declared env into .env on enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every plugin lists the environment it reads in module.json `config[]`, and the kernel FAILS THE BOOT when a required one is absent (ValidateConfigStage). Until now enabling a plugin left the operator to discover that list from a stack trace, one variable per boot attempt. Enabling now writes the whole set into .env at once, in three shapes, and the difference between them is load-bearing: default present KEY=value written ACTIVE — the documented default required, no default KEY= written ACTIVE but empty — boot still fails, but it fails pointing at a line you can see optional, no default # KEY= written COMMENTED — documents the knob without pinning a value --- tools/src/commands/plugins.zig | 59 ++++++++ tools/src/lib/plugin_env.zig | 259 +++++++++++++++++++++++++++++++++ tools/src/tests.zig | 1 + 3 files changed, 319 insertions(+) create mode 100644 tools/src/lib/plugin_env.zig diff --git a/tools/src/commands/plugins.zig b/tools/src/commands/plugins.zig index eabe5e3..8ad6281 100644 --- a/tools/src/commands/plugins.zig +++ b/tools/src/commands/plugins.zig @@ -15,6 +15,7 @@ const util = @import("../lib/util.zig"); const sources = @import("../lib/plugin_sources.zig"); const boot = @import("../lib/plugin_bootstrap.zig"); const assets = @import("../lib/plugin_assets.zig"); +const penv = @import("../lib/plugin_env.zig"); const ui = @import("../lib/plugin_ui.zig"); const deps = @import("../lib/plugin_deps.zig"); const installer = @import("../lib/plugin_install.zig"); @@ -998,6 +999,19 @@ fn enableOne( for (preview.items) |p| prompt.muted(try std.fmt.allocPrint(allocator, " {s}", .{p})); prompt.muted(" + would run migrate:run --force"); } + + const vars = try penv.readVars(allocator, io, cd, folder); + if (vars.len > 0) { + const plan = try penv.seed(allocator, io, root, folder, vars, true); + if (plan.added.len > 0) { + prompt.muted(try std.fmt.allocPrint( + allocator, + " + would add {d} env var(s) to .env ({d} already present):", + .{ plan.added.len, plan.skipped }, + )); + for (plan.added) |v| prompt.muted(try std.fmt.allocPrint(allocator, " {s}", .{v.key})); + } + } } return updated; } @@ -1010,6 +1024,51 @@ fn enableOne( prompt.muted(try std.fmt.allocPrint(allocator, " wired Support/helpers.php (require_once {s})", .{expr})); if (chosenDir) |cd| { + // Seed the plugin's declared env vars BEFORE migrations run: a + // migration reads the database config, and the whole point of writing + // the block is that the operator can see and set it first. + const vars = try penv.readVars(allocator, io, cd, folder); + if (vars.len > 0) { + const seeded = penv.seed(allocator, io, root, folder, vars, false) catch |e| blk: { + prompt.warn(try std.fmt.allocPrint( + allocator, + "could not write .env ({t}) — add {s}'s config[] variables by hand.", + .{ e, folder }, + )); + break :blk penv.Seeded{ .added = &.{}, .skipped = 0, .path = "", .created = false }; + }; + + if (seeded.added.len > 0) { + if (seeded.created) prompt.muted(" created .env"); + prompt.ok(try std.fmt.allocPrint( + allocator, + "Added {d} env var(s) to .env ({d} already present)", + .{ seeded.added.len, seeded.skipped }, + )); + + // Name the ones that BLOCK a boot separately. Everything else is + // a knob with a working default; these are the ones the operator + // has to act on, and burying them in a list of twenty would mean + // finding out from a failed boot instead. + var needs_value: usize = 0; + for (seeded.added) |v| { + if (v.required and v.default == null) needs_value += 1; + } + if (needs_value > 0) { + prompt.warn(try std.fmt.allocPrint( + allocator, + "{d} of them are REQUIRED and have no default — the boot fails until you set them:", + .{needs_value}, + )); + for (seeded.added) |v| { + if (v.required and v.default == null) { + prompt.muted(try std.fmt.allocPrint(allocator, " {s}", .{v.key})); + } + } + } + } + } + const fp = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ cd, folder }); var published: std.ArrayList([]const u8) = .empty; try assets.publishAssets(allocator, io, fp, root, &published); diff --git a/tools/src/lib/plugin_env.zig b/tools/src/lib/plugin_env.zig new file mode 100644 index 0000000..6e2201f --- /dev/null +++ b/tools/src/lib/plugin_env.zig @@ -0,0 +1,259 @@ +//! Seed a plugin's declared env vars into the project's `.env`. +//! +//! Every plugin lists the environment it reads in `module.json` `config[]`, and +//! the kernel FAILS THE BOOT when a required one is absent (ValidateConfigStage). +//! Before this, enabling a plugin left the operator to discover that list from a +//! stack trace, one variable per boot attempt. Enabling now writes the whole set +//! into `.env` at once, so the knobs are visible where you configure things. +//! +//! Three shapes, and the difference between them is load-bearing: +//! +//! default present KEY=value written ACTIVE — the documented default +//! required, no default KEY= written ACTIVE but EMPTY +//! optional, no default # KEY= written COMMENTED +//! +//! An empty value is not the same as an absent one. ValidateConfigStage treats +//! `''` as missing (`$value === null || $value === ''`), so a required var +//! written empty still fails the boot loudly until someone supplies a real +//! secret — which is what should happen. An OPTIONAL var written empty would +//! instead be read as the string `''` and silently beat the plugin's own +//! internal default, so those stay commented: present and documented, but not +//! overriding anything. +//! +//! Nothing already in the file is ever touched. Re-enabling a plugin, or +//! enabling a second one that shares a variable, adds only what is missing. + +const std = @import("std"); +const util = @import("util.zig"); + +const Io = std.Io; +const Dir = std.Io.Dir; + +/// One declared variable from a plugin's `module.json` `config[]`. +pub const Var = struct { + key: []const u8, + /// "string" | "int" | "float" | "bool" — informational, written as a comment. + type_name: ?[]const u8 = null, + required: bool = true, + /// Rendered default. Null when the plugin declared none. + default: ?[]const u8 = null, +}; + +pub const Seeded = struct { + /// Variables written into the file. + added: []const Var, + /// Variables already present (in any form) and therefore left alone. + skipped: usize, + /// The file that was (or would be) written. + path: []const u8, + /// True when the .env did not exist and was created. + created: bool, +}; + +/// Read `config[]` out of a plugin's module.json. +/// +/// Accepts both declared shapes: a bare string (`"APP_KEY"`, required, untyped) +/// and the object form (`{ "key": …, "type": …, "required": …, "default": … }`). +pub fn readVars( + allocator: std.mem.Allocator, + io: Io, + pluginsDir: []const u8, + name: []const u8, +) ![]const Var { + const path = try std.fmt.allocPrint(allocator, "{s}/{s}/module.json", .{ pluginsDir, name }); + const content = Dir.cwd().readFileAlloc(io, path, allocator, .limited(4 * 1024 * 1024)) catch return &.{}; + + const trimmed = std.mem.trim(u8, content, " \t\r\n"); + if (trimmed.len == 0) return &.{}; + + const parsed = std.json.parseFromSliceLeaky(std.json.Value, allocator, trimmed, .{}) catch return &.{}; + if (parsed != .object) return &.{}; + + const config = parsed.object.get("config") orelse return &.{}; + if (config != .array) return &.{}; + + var out: std.ArrayList(Var) = .empty; + for (config.array.items) |entry| { + switch (entry) { + .string => |s| { + if (s.len == 0) continue; + try out.append(allocator, .{ .key = s }); + }, + .object => |o| { + const key = switch (o.get("key") orelse continue) { + .string => |s| s, + else => continue, + }; + if (key.len == 0) continue; + + try out.append(allocator, .{ + .key = key, + .type_name = switch (o.get("type") orelse std.json.Value{ .null = {} }) { + .string => |s| s, + else => null, + }, + // Absent means required — same default the kernel applies. + .required = switch (o.get("required") orelse std.json.Value{ .bool = true }) { + .bool => |b| b, + else => true, + }, + .default = try renderDefault(allocator, o.get("default")), + }); + }, + else => {}, + } + } + + return out.items; +} + +/// Render a JSON default as it should appear on the right of `KEY=`. +/// +/// An explicit JSON `null` is NOT a default — it means "no value", which is +/// exactly the state an absent key already expresses. +fn renderDefault(allocator: std.mem.Allocator, value: ?std.json.Value) !?[]const u8 { + const v = value orelse return null; + return switch (v) { + .string => |s| s, + .integer => |i| try std.fmt.allocPrint(allocator, "{d}", .{i}), + .float => |f| try std.fmt.allocPrint(allocator, "{d}", .{f}), + .bool => |b| if (b) "true" else "false", + .null => null, + // An array or object cannot be expressed in a dotenv value. + else => null, + }; +} + +/// True when `key` already appears in the file, whether set or commented out. +/// +/// A commented entry counts as present on purpose: it means a previous seed (or +/// a person) already put that knob in front of the operator, and writing it a +/// second time would grow the file every time a plugin is re-enabled. +pub fn hasKey(content: []const u8, key: []const u8) bool { + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + var line = std.mem.trim(u8, raw, " \t\r"); + if (line.len == 0) continue; + + // Look past a comment marker so `# KEY=` is recognised too. + while (line.len > 0 and (line[0] == '#' or line[0] == ' ' or line[0] == '\t')) { + line = line[1..]; + line = std.mem.trimStart(u8, line, " \t"); + } + if (line.len <= key.len) continue; + if (!std.mem.startsWith(u8, line, key)) continue; + + // Must be followed by '=' — otherwise APP_KEY would match APP_KEY_ID. + const rest = std.mem.trimStart(u8, line[key.len..], " \t"); + if (rest.len > 0 and rest[0] == '=') return true; + } + return false; +} + +/// Append every variable of `vars` that the project's `.env` does not already +/// mention, under a labelled block. Creates the file when absent. +pub fn seed( + allocator: std.mem.Allocator, + io: Io, + projectRoot: []const u8, + pluginName: []const u8, + vars: []const Var, + dry_run: bool, +) !Seeded { + const path = try std.fs.path.join(allocator, &.{ projectRoot, ".env" }); + + const existing = Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch ""; + const created = existing.len == 0 and !util.fileExists(io, path); + + var missing: std.ArrayList(Var) = .empty; + var skipped: usize = 0; + for (vars) |v| { + if (hasKey(existing, v.key)) { + skipped += 1; + } else { + try missing.append(allocator, v); + } + } + + if (missing.items.len == 0 or dry_run) { + return .{ .added = missing.items, .skipped = skipped, .path = path, .created = created }; + } + + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(allocator, existing); + + // Exactly one blank line before the block, whatever the file ended with. + if (out.items.len > 0) { + while (out.items.len > 0 and (out.items[out.items.len - 1] == '\n' or out.items[out.items.len - 1] == '\r')) { + _ = out.pop(); + } + try out.appendSlice(allocator, "\n\n"); + } + + try out.appendSlice(allocator, try std.fmt.allocPrint( + allocator, + "# ─── {s} ─────────────────────────────────────────────────\n" ++ + "# Declared in the plugin's module.json config[]. Added by `hkm plugins enable`.\n", + .{pluginName}, + )); + + for (missing.items) |v| { + if (v.default) |d| { + try out.appendSlice(allocator, try std.fmt.allocPrint(allocator, "{s}={s}\n", .{ v.key, d })); + continue; + } + + if (v.required) { + // Active but empty. The kernel counts '' as missing, so the boot + // still stops here until a real value is supplied — which is the + // correct outcome for something like an API key. + try out.appendSlice(allocator, try std.fmt.allocPrint( + allocator, + "{s}= # REQUIRED{s} — set this before booting\n", + .{ v.key, typeSuffix(allocator, v.type_name) }, + )); + continue; + } + + // Optional with no default: COMMENTED. Writing it empty would be read as + // the string '' and would quietly beat the plugin's own default. + try out.appendSlice(allocator, try std.fmt.allocPrint( + allocator, + "# {s}= # optional{s}\n", + .{ v.key, typeSuffix(allocator, v.type_name) }, + )); + } + + Dir.cwd().writeFile(io, .{ .sub_path = path, .data = out.items }) catch |e| return e; + + // A .env holds secrets; a freshly created one should not be world-readable. + if (created) util.chmod600(io, path); + + return .{ .added = missing.items, .skipped = skipped, .path = path, .created = created }; +} + +fn typeSuffix(allocator: std.mem.Allocator, type_name: ?[]const u8) []const u8 { + const t = type_name orelse return ""; + if (t.len == 0) return ""; + return std.fmt.allocPrint(allocator, " ({s})", .{t}) catch ""; +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +test "hasKey matches a set value" { + try std.testing.expect(hasKey("FOO=1\nBAR=2\n", "FOO")); +} + +test "hasKey matches a commented entry so re-enabling does not duplicate it" { + try std.testing.expect(hasKey("# FOO=\n", "FOO")); + try std.testing.expect(hasKey("#FOO=\n", "FOO")); +} + +test "hasKey does not match a longer key with the same prefix" { + try std.testing.expect(!hasKey("APP_KEY_ID=x\n", "APP_KEY")); + try std.testing.expect(hasKey("APP_KEY_ID=x\nAPP_KEY=y\n", "APP_KEY")); +} + +test "hasKey ignores a key mentioned only in prose" { + try std.testing.expect(!hasKey("# see APP_KEY for details\n", "APP_KEY")); +} diff --git a/tools/src/tests.zig b/tools/src/tests.zig index bb2bf66..13f95c7 100644 --- a/tools/src/tests.zig +++ b/tools/src/tests.zig @@ -46,6 +46,7 @@ test { _ = @import("lib/memory.zig"); _ = @import("lib/plugin_assets.zig"); _ = @import("lib/plugin_bootstrap.zig"); + _ = @import("lib/plugin_env.zig"); _ = @import("lib/plugin_deps.zig"); _ = @import("lib/plugin_domains.zig"); _ = @import("lib/plugin_git.zig"); From 1cb12b9fc74ceb0cabd3ef8a5c89dcc7c4b556b9 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 22:40:10 +0300 Subject: [PATCH 133/140] feat(install): a user-local tarball install that needs no root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The .deb was the only install path, so trying the kernel meant apt, sudo and a system-wide PHP — a high price for "does this work on my machine", and impossible on a box you do not own. Linux release builds now produce TWO artifacts and the tarball is the primary one: tools/install.sh unpacks the kernel and launcher entirely inside $HOME, writes nothing outside it, and needs no privileges. install.sh is published alongside the assets so `curl … | sh` works without a checkout. The .deb stays for multi-user machines and CI images, where a system-wide install and apt-managed PHP are the point. `hkm doctor` grew the diagnostics this makes necessary: which install is actually being used, and whether a stale HKM_KERNEL_HOME pin in ~/.config/hkm/config.env is overriding it — the failure that otherwise presents as "my changes do nothing". --- .github/workflows/release.yml | 14 +- tools/README.md | 76 +++++++++ tools/bundle.sh | 80 +++++++-- tools/install.sh | 285 +++++++++++++++++++++++++++++++ tools/src/commands/doctor.zig | 309 +++++++++++++++++++++++++++++----- 5 files changed, 706 insertions(+), 58 deletions(-) create mode 100755 tools/install.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4dd44fe..a6502d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,9 +48,9 @@ jobs: # macOS universal binary is assembled with llvm-lipo. This needs only ONE # self-hosted Zig toolchain (Linux) and no macOS runner. - # ── Linux: .deb (amd64) ──────────────────────────────────────────────────── + # ── Linux: portable tarball (default install) + .deb (system-wide) ──────── build-linux: - name: Build Linux .deb + name: Build Linux tarball + .deb needs: test runs-on: ubuntu-22.04 steps: @@ -63,8 +63,16 @@ jobs: with: { php-version: "8.4", tools: composer } - name: Bundle (linux) run: VERSION="${RELEASE_VERSION:-${GITHUB_REF_NAME#v}}" ./tools/bundle.sh linux + # TWO artifacts. The tarball is the DEFAULT install path — user-local, + # no root (tools/install.sh). The .deb is for multi-user machines and CI + # images where a system-wide install and apt-managed PHP are the point. + - uses: actions/upload-artifact@v5 + with: { name: linux-tarball, path: dist/*linux*.tar.gz } - uses: actions/upload-artifact@v5 with: { name: linux-deb, path: dist/*.deb } + # Published alongside the assets so `curl | sh` works without a checkout. + - uses: actions/upload-artifact@v5 + with: { name: installer, path: tools/install.sh } # ── Windows: .zip (x86_64, cross-compiled) ──────────────────────────────── build-windows: @@ -135,9 +143,11 @@ jobs: # explicitly; on a tag push this matches GITHUB_REF_NAME anyway. tag_name: v${{ steps.notes.outputs.version }} files: | + artifacts/linux-tarball/*.tar.gz artifacts/linux-deb/*.deb artifacts/windows-zip/*.zip artifacts/macos-app/*.tar.gz + artifacts/installer/install.sh # Curated section (if present) goes first; GitHub appends the # auto-generated "What's Changed" / contributors below it. body_path: ${{ steps.notes.outputs.has_notes == 'true' && 'release-body.md' || '' }} diff --git a/tools/README.md b/tools/README.md index df4bda0..99f8ea8 100644 --- a/tools/README.md +++ b/tools/README.md @@ -3,6 +3,82 @@ Native launcher + project tooling for the AlfacodeTeam PhpServicePlatform. Builds two binaries: `hkm` (the launcher/CLI) and `hkm-config`. +## Install (no root) + +```sh +# latest release, into ~/.local — nothing is written outside your home +curl -fsSL https://github.com/AlfaCode-Team/hkm-kernel/releases/latest/download/install.sh | sh + +# or, from a downloaded tarball / this checkout +./tools/install.sh hkm-kernel-1.2.3-linux-x86_64.tar.gz +./tools/install.sh --version v1.2.3 +HKM_PREFIX=/srv/hkm ./tools/install.sh +./tools/install.sh --uninstall +``` + +| Path | Holds | +|---|---| +| `~/.local/bin/hkm`, `hkm-config` | the launcher | +| `~/.local/lib/hkm-kernel/` | kernel source + `vendor/` | +| `~/.config/hkm/config.env` | launcher config — outside the install tree | +| `~/.local/share/hkm/` | project registry — outside the install tree | + +Upgrades replace the kernel tree but carry `projects/projects.json` and +`projects/platform.json` across; `--uninstall` leaves your config and registry +alone. + +**The `bin/` + `lib/hkm-kernel/` pairing is load-bearing.** `resolveHome()` in +`src/lib/kernel.zig` probes `/lib/hkm-kernel`, which is why +the launcher finds its kernel with no env var and no config file — both from an +extracted tarball run in place and from `~/.local`. Change one side and you must +change the other. + +**The install has no preconditions.** It does not require PHP, composer, git or +node to be present — it copies files into your home, runs `hkm doctor`, and +finishes successfully either way. Gating it on a runtime an administrator has +not installed yet would leave you without the binary that tells you what to ask +for. + +## `hkm doctor` — what the kernel needs + +The single authority on whether this machine can run the kernel. It enumerates +every requirement, not just PHP: + +| Section | Checks | +|---|---| +| Launcher | this binary, whether its dir is on `PATH`, whether another `hkm` shadows it | +| Kernel | kernel root, PHP CLI, `composer.json`, `src/`, the four first-party `modules/`, `vendor/autoload.php`, writability | +| Configuration | `config.env`, a stale `HKM_KERNEL_HOME` pin, userdata dir + writability, the registry | +| Tooling | `php`, `composer`, `git`, `node`, `npm` — each labelled with what needs it | +| PHP runtime | version >= 8.4.1, the nine required extensions, a PDO driver, `memory_limit`, plus optional redis/swoole/gd/intl/zip/sodium/opcache | + +Output ends in two lists, each line carrying the command that fixes it: + +- **Must fix** — blocks the kernel. Exit code 1, so `hkm doctor` gates CI. +- **Worth fixing** — warns only. Exit code 0. + +The extension and version checks are asked of PHP itself through a `php -r` +preflight, so they describe the exact runtime a project will use rather than a +guess. With no `php` on PATH that section is skipped and reported, not fatal to +the run. + +Installing **PHP and its extensions** is the one part that needs an +administrator. Everything else `doctor` reports, you can fix yourself. + +### The `.deb` (system-wide, needs root) + +`hkm-kernel__amd64.deb` installs `/opt/hkm-kernel` + `/usr/bin/hkm` for +**all** users, with apt managing the PHP dependency chain. Use it for multi-user +machines, servers and CI images. It is the exception; the tarball is the default. + +Root is required there for packaging reasons only — `dpkg` must run as root, +`/opt` and `/usr/bin` are root-owned, `Depends:` drives apt, and `postinst` runs +composer into a root-owned tree. The launcher itself has never needed root. + +Note `/usr/bin` normally precedes `~/.local/bin` on `PATH`, so a leftover `.deb` +install silently shadows a user install. `install.sh` warns when it sees one; +remove it with `sudo apt remove hkm-kernel`. + ## Layout ``` diff --git a/tools/bundle.sh b/tools/bundle.sh index 8cdadcb..5f605eb 100755 --- a/tools/bundle.sh +++ b/tools/bundle.sh @@ -3,9 +3,19 @@ # bundle.sh — build the `hkm` launcher for every OS and assemble installable # bundles under dist/. Run from the repo root or from tools/. # -# ./tools/bundle.sh # build all: linux .deb tree, macos, windows zip +# ./tools/bundle.sh # build all: linux tarball + .deb, macos, windows # ./tools/bundle.sh linux # only one target # +# Linux produces TWO artifacts, and the tarball is the primary one: +# +# hkm-kernel--linux-x86_64.tar.gz user-local / portable. No root. Extract +# anywhere, or install into ~/.local with +# tools/install.sh. +# hkm-kernel__amd64.deb system-wide. Needs root; use it for +# multi-user machines, servers and CI +# images where apt managing the PHP +# dependency chain is the point. +# # What a bundle contains: # • the native `hkm` + `hkm-config` launcher (Zig, statically linked) # • the kernel PHP source (src/) + vendor/ (composer --no-dev) @@ -100,19 +110,52 @@ build_zig() { # $1 = zig target triple, $2 = out dir rm -rf "$DIST"; mkdir -p "$DIST" -# ─── Linux: .deb (amd64) ──────────────────────────────────────────────────── +# ─── Linux: portable tarball (amd64) — the DEFAULT install path ───────────── +# Layout is chosen so the launcher self-locates with no env var and no config: +# resolveHome() probes "/lib/hkm-kernel" (tools/src/lib/ +# kernel.zig), so bin/ + lib/ side by side works BOTH when the tree is extracted +# somewhere and run in place, and when install.sh copies it into ~/.local — +# because the relative layout is identical in both cases. +# +# hkm-kernel--linux-x86_64/ +# ├── bin/hkm, bin/hkm-config +# ├── lib/hkm-kernel/ (kernel source; vendor/ built by install.sh) +# └── install.sh user-local installer, no root if [[ "$want" == all || "$want" == linux ]]; then build_zig x86_64-linux-gnu "$DIST/_zig/linux" + TB="hkm-kernel-${VERSION}-linux-x86_64"; T="$DIST/$TB" + mkdir -p "$T/bin" + stage_kernel "$T/lib/$KERNEL_DIRNAME" + cp "$DIST/_zig/linux/bin/hkm" "$T/bin/hkm" + cp "$DIST/_zig/linux/bin/hkm-config" "$T/bin/hkm-config" + chmod +x "$T/bin/hkm" "$T/bin/hkm-config" + cp "$TOOLS/install.sh" "$T/install.sh" + chmod +x "$T/install.sh" + ( cd "$DIST" && tar -czf "${TB}.tar.gz" "$TB" && rm -rf "$TB" ) + say "wrote $DIST/${TB}.tar.gz (user-local, no root)" +fi + +# ─── Linux: .deb (amd64) — system-wide, needs root ────────────────────────── +if [[ "$want" == all || "$want" == linux ]]; then PKG="hkm-kernel_${VERSION}_amd64"; P="$DIST/$PKG" mkdir -p "$P/DEBIAN" "$P/usr/bin" stage_kernel "$P/opt/$KERNEL_DIRNAME" cp "$DIST/_zig/linux/bin/hkm" "$P/usr/bin/hkm" cp "$DIST/_zig/linux/bin/hkm-config" "$P/usr/bin/hkm-config" chmod +x "$P/usr/bin/hkm" "$P/usr/bin/hkm-config" - # composer is a hard dependency now: the package ships SOURCE, not vendor/, and - # resolves dependencies on the target in postinst. Network access is required - # at install time. In MODULES=git mode, git is also required to fetch modules. - DEPS="php8.4-cli, php8.4-mbstring, php8.4-curl, php8.4-xml, php8.4-zip, composer, ca-certificates" + # PHP is RECOMMENDED, not required — same principle as the user-local install: + # putting the package on disk must not be gated on a runtime an administrator + # may install differently (ondrej PPA, Sury, a hand-built PHP, a container base + # image). apt installs Recommends by default, so the common case is unchanged; + # what changes is that a machine whose repos have no `php8.4-*` can still + # install hkm and be TOLD what is missing by `hkm doctor`, instead of dpkg + # refusing and leaving the operator with nothing to run. + # + # ca-certificates stays a hard dependency: postinst fetches over TLS, and + # without it that fails in a way no diagnostic can explain. + DEPS="ca-certificates" + RECS="php8.4-cli, php8.4-mbstring, php8.4-curl, php8.4-xml, php8.4-zip, composer" + RECS="$RECS, php8.4-mysql | php8.4-pgsql | php8.4-sqlite3, php8.4-redis, php8.4-intl" [ "$MODULES" = git ] && DEPS="$DEPS, git" cat > "$P/DEBIAN/control" < Depends: ${DEPS} -Recommends: php8.4-mysql | php8.4-pgsql | php8.4-sqlite3, php8.4-redis, php8.4-intl +Recommends: ${RECS} Description: PhpServicePlatform (HKM) kernel and native launcher Installs the kernel PHP source (src, plugins, projects, modules) under /opt/hkm-kernel and a native hkm launcher in /usr/bin. PHP dependencies are resolved with composer at install time (vendor/ is not bundled), so the runtime matches this machine's PHP. Needs network access during install. - Run 'hkm doctor' afterwards to verify PHP and required extensions. + . + PHP and composer are Recommends rather than Depends, so this package installs + even where they are provided by another repository or built by hand. Run + 'hkm doctor' afterwards: it lists every requirement the kernel has and the + command to fix each one that is missing. + . + For a single user, prefer the user-local tarball install (no root): + hkm-kernel--linux-x86_64.tar.gz + install.sh. EOF # conffiles: the project registry + platform map are USER DATA. Marking them as # dpkg conffiles makes upgrades PRESERVE the user's versions instead of @@ -141,14 +191,20 @@ EOF #!/bin/sh set -e KERNEL=/opt/${KERNEL_DIRNAME} -echo "hkm-kernel: resolving PHP dependencies with composer…" -if [ -x "\$KERNEL/install.sh" ]; then +# Best effort, never fatal. PHP is a Recommends, so it may legitimately be +# absent here; failing the package install would leave the operator with no +# hkm binary and therefore no way to run the diagnostic that explains why. +if ! command -v php >/dev/null 2>&1; then + echo "hkm-kernel: no php on PATH yet — skipping dependency resolution." + echo "hkm-kernel: run 'hkm doctor' to see what is required." +elif [ -x "\$KERNEL/install.sh" ]; then + echo "hkm-kernel: resolving PHP dependencies with composer…" ( cd "\$KERNEL" && ./install.sh ) || { - echo "WARNING: composer install failed. Fix connectivity/PHP, then run:"; + echo "WARNING: dependency resolution did not complete. After fixing it:"; echo " sudo sh -c 'cd \$KERNEL && ./install.sh'"; } fi -echo "hkm-kernel installed. Verify your environment with: hkm doctor" +echo "hkm-kernel installed. See what it still needs with: hkm doctor" exit 0 EOF chmod +x "$P/DEBIAN/postinst" diff --git a/tools/install.sh b/tools/install.sh new file mode 100755 index 0000000..487c9b6 --- /dev/null +++ b/tools/install.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env sh +# --------------------------------------------------------------------------- +# install.sh — install the HKM kernel + launcher for the CURRENT USER. +# +# No root. Nothing is written outside your home directory. +# +# ./install.sh # download the latest release, install +# ./install.sh hkm-kernel-1.2.3-linux-x86_64.tar.gz +# ./install.sh --version v1.2.3 # download a specific tag +# HKM_PREFIX=/srv/hkm ./install.sh # install somewhere else +# ./install.sh --uninstall +# +# Installs: +# $HKM_PREFIX/bin/hkm, hkm-config (default: ~/.local/bin) +# $HKM_PREFIX/lib/hkm-kernel/ kernel source + vendor/ +# +# That relative layout is not arbitrary: the launcher resolves its kernel by +# probing "/lib/hkm-kernel" (tools/src/lib/kernel.zig), +# so bin/ and lib/ side by side means self-location works with no environment +# variable and no config file. +# +# Your data is NOT inside the install tree and survives upgrades: +# ~/.config/hkm/config.env launcher config +# ~/.local/share/hkm/ project registry (HKM_USERDATA_DIR) +# +# Still needed from your system administrator, once: PHP >= 8.4 with the +# extensions `hkm doctor` lists. Installing PHP is the one thing a user-local +# install genuinely cannot do for you. +# --------------------------------------------------------------------------- +set -eu + +REPO="${HKM_REPO:-AlfaCode-Team/hkm-kernel}" +PREFIX="${HKM_PREFIX:-$HOME/.local}" +KERNEL_DIRNAME="hkm-kernel" +DEST="$PREFIX/lib/$KERNEL_DIRNAME" +BINDIR="$PREFIX/bin" + +TARBALL="" +WANT_TAG="" +DO_UNINSTALL=0 +SKIP_COMPOSER=0 + +# ── output helpers ────────────────────────────────────────────────────────── +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_B='\033[36m'; C_G='\033[32m'; C_Y='\033[33m'; C_R='\033[31m'; C_0='\033[0m' +else + C_B=''; C_G=''; C_Y=''; C_R=''; C_0='' +fi +say() { printf "${C_B}▶${C_0} %s\n" "$*"; } +ok() { printf "${C_G}✓${C_0} %s\n" "$*"; } +warn() { printf "${C_Y}!${C_0} %s\n" "$*" >&2; } +die() { printf "${C_R}✗${C_0} %s\n" "$*" >&2; exit 1; } + +usage() { + sed -n '3,30p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +# ── arguments ─────────────────────────────────────────────────────────────── +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) usage ;; + --uninstall) DO_UNINSTALL=1 ;; + --version) shift; [ $# -gt 0 ] || die "--version needs a tag (e.g. v1.2.3)"; WANT_TAG="$1" ;; + --prefix) shift; [ $# -gt 0 ] || die "--prefix needs a path"; PREFIX="$1" + DEST="$PREFIX/lib/$KERNEL_DIRNAME"; BINDIR="$PREFIX/bin" ;; + --no-composer) SKIP_COMPOSER=1 ;; + -*) die "unknown option: $1 (try --help)" ;; + *) TARBALL="$1" ;; + esac + shift +done + +# ── uninstall ─────────────────────────────────────────────────────────────── +if [ "$DO_UNINSTALL" -eq 1 ]; then + say "Removing $DEST" + rm -rf "$DEST" + rm -f "$BINDIR/hkm" "$BINDIR/hkm-config" + ok "Removed. Your data was left alone:" + printf ' %s\n %s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/hkm" \ + "${XDG_DATA_HOME:-$HOME/.local/share}/hkm" + printf ' Delete those too if you want a clean slate.\n' + exit 0 +fi + +# ── a system install would shadow this one ────────────────────────────────── +# /usr/bin usually precedes ~/.local/bin on PATH, so a leftover .deb install +# silently wins and the user debugs the wrong copy. +if [ -x /usr/bin/hkm ] && [ "$PREFIX" = "$HOME/.local" ]; then + warn "A system-wide hkm exists at /usr/bin/hkm (installed from the .deb)." + warn "It will take priority on PATH over this user install." + warn "Remove it first with: sudo apt remove hkm-kernel" +fi + +# ── acquire the tarball ───────────────────────────────────────────────────── +TMP="$(mktemp -d)" +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT INT TERM + +fetch() { # $1 = url, $2 = out + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$1" -o "$2" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" + else + die "need curl or wget to download (or pass a .tar.gz path)" + fi +} + +arch_slug() { + case "$(uname -m)" in + x86_64|amd64) echo "x86_64" ;; + aarch64|arm64) echo "aarch64" ;; + *) die "unsupported architecture: $(uname -m)" ;; + esac +} + +if [ -n "$TARBALL" ]; then + [ -f "$TARBALL" ] || die "no such file: $TARBALL" + say "Using $TARBALL" +else + OS="$(uname -s)" + [ "$OS" = "Linux" ] || die "auto-download supports Linux; on $OS use the .app/.zip bundle, or pass a tarball" + ARCH="$(arch_slug)" + + if [ -n "$WANT_TAG" ]; then + TAG="$WANT_TAG" + else + say "Looking up the latest release of $REPO…" + API="https://api.github.com/repos/$REPO/releases/latest" + fetch "$API" "$TMP/rel.json" || die "could not reach GitHub. Download the tarball and pass its path." + # Deliberately not jq — this script must run on a bare machine. + TAG="$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$TMP/rel.json" | head -1)" + [ -n "$TAG" ] || die "could not parse the latest tag. Pass --version vX.Y.Z or a tarball path." + fi + + VER="${TAG#v}" + NAME="hkm-kernel-${VER}-linux-${ARCH}.tar.gz" + URL="https://github.com/$REPO/releases/download/$TAG/$NAME" + say "Downloading $NAME" + fetch "$URL" "$TMP/$NAME" || die "download failed: $URL" + TARBALL="$TMP/$NAME" +fi + +# ── unpack and validate before touching the destination ───────────────────── +say "Unpacking…" +mkdir -p "$TMP/x" +tar -xzf "$TARBALL" -C "$TMP/x" || die "could not extract $TARBALL" + +# The archive has a single top-level directory. +SRC="$(find "$TMP/x" -mindepth 1 -maxdepth 1 -type d | head -1)" +[ -n "$SRC" ] || die "unexpected archive layout (no top-level directory)" +[ -f "$SRC/lib/$KERNEL_DIRNAME/composer.json" ] \ + || die "this does not look like an hkm tarball (no lib/$KERNEL_DIRNAME/composer.json)" +[ -x "$SRC/bin/hkm" ] || die "launcher missing from the archive (bin/hkm)" + +# ── preserve the registry across an upgrade ───────────────────────────────── +# projects.json / platform.json are USER DATA that happen to live in the kernel +# tree. `hkm-config check` (run at the end) migrates them OUT to the userdata +# dir, which is the permanent fix — but a user who has never run it still has +# their only copy in here, and the swap below would delete it. So carry them +# across first. The .deb marks the same two files as dpkg conffiles. +PRESERVE="projects/projects.json projects/platform.json" +if [ -d "$DEST" ]; then + for rel in $PRESERVE; do + if [ -f "$DEST/$rel" ]; then + mkdir -p "$SRC/lib/$KERNEL_DIRNAME/$(dirname "$rel")" + cp "$DEST/$rel" "$SRC/lib/$KERNEL_DIRNAME/$rel" + say "Kept your $rel" + fi + done +fi + +# ── install ───────────────────────────────────────────────────────────────── +mkdir -p "$BINDIR" "$PREFIX/lib" + +# Swap rather than overwrite in place: a half-copied kernel is worse than an old +# one, and rm -rf on the live tree would break a concurrent `hkm` invocation. +NEW="$PREFIX/lib/.$KERNEL_DIRNAME.new.$$" +OLD="$PREFIX/lib/.$KERNEL_DIRNAME.old.$$" +rm -rf "$NEW" +cp -R "$SRC/lib/$KERNEL_DIRNAME" "$NEW" + +if [ -d "$DEST" ]; then mv "$DEST" "$OLD"; fi +mv "$NEW" "$DEST" +rm -rf "$OLD" + +cp "$SRC/bin/hkm" "$BINDIR/hkm" +cp "$SRC/bin/hkm-config" "$BINDIR/hkm-config" +chmod +x "$BINDIR/hkm" "$BINDIR/hkm-config" +ok "Installed to $DEST" + +# ── repoint a stale kernel pin ────────────────────────────────────────────── +# This is not cosmetic. The launcher loads ~/.config/hkm/config.env into its +# environment BEFORE resolving (main.zig), and resolveHome() checks +# HKM_KERNEL_HOME FIRST — ahead of self-location. So a pin left over from an +# earlier install at a different path silently wins, and `hkm` keeps running the +# OLD kernel while this one sits unused. Repoint it, or self-location never gets +# a look in. +CFG="${XDG_CONFIG_HOME:-$HOME/.config}/hkm/config.env" +if [ -f "$CFG" ]; then + PINNED="$(sed -n 's/^[[:space:]]*HKM_KERNEL_HOME[[:space:]]*=[[:space:]]*//p' "$CFG" | tail -1)" + if [ -n "$PINNED" ] && [ "$PINNED" != "$DEST" ]; then + warn "config.env pins HKM_KERNEL_HOME=$PINNED" + warn "That would override this install. Repointing it to $DEST" + "$BINDIR/hkm-config" set-kernel-home "$DEST" >/dev/null 2>&1 \ + || die "could not repoint HKM_KERNEL_HOME — edit $CFG by hand, then re-run" + ok "Repointed HKM_KERNEL_HOME" + fi +fi + +# ── PHP dependencies (best effort — NEVER a precondition) ─────────────────── +# Installing is unconditional on purpose. Copying files into your own home +# cannot fail for want of PHP, and refusing to do it until an administrator has +# installed php8.4-mbstring helps nobody: you end up unable to even read `hkm +# doctor`, which is the thing that would have told you what to ask for. +# +# So: try to build vendor/, and if anything is missing just say so and finish. +# `hkm doctor` is the single authority on whether the environment can actually +# run the kernel, and it is installed and usable either way. +if [ "$SKIP_COMPOSER" -eq 1 ]; then + say "Skipping dependency resolution (--no-composer)." +elif ! command -v php >/dev/null 2>&1; then + say "No php on PATH — skipping dependency resolution for now." +elif [ -x "$DEST/install.sh" ]; then + say "Resolving PHP dependencies (composer install --no-dev)…" + # The kernel ships its own composer/modules helper (it also handles + # modules.lock and falls back to downloading composer.phar). + ( cd "$DEST" && ./install.sh ) || warn "Dependency resolution did not complete — hkm doctor will show why." +else + say "Resolving PHP dependencies (composer install --no-dev)…" + ( cd "$DEST" && composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist ) \ + || warn "Dependency resolution did not complete — hkm doctor will show why." +fi + +# ── PATH ──────────────────────────────────────────────────────────────────── +case ":${PATH}:" in + *":$BINDIR:"*) ok "$BINDIR is on your PATH" ;; + *) + warn "$BINDIR is NOT on your PATH." + printf ' Add it, then open a new terminal:\n' + case "${SHELL##*/}" in + zsh) printf ' echo '\''export PATH="%s:$PATH"'\'' >> ~/.zshrc\n' "$BINDIR" ;; + fish) printf ' fish_add_path %s\n' "$BINDIR" ;; + *) printf ' echo '\''export PATH="%s:$PATH"'\'' >> ~/.bashrc\n' "$BINDIR" ;; + esac + ;; +esac + +# ── pin config + move the registry out of the kernel tree ─────────────────── +# `hkm-config check` is the canonical step: it pins HKM_KERNEL_HOME, then +# creates ~/.local/share/hkm and MIGRATES projects.json + platform.json out of +# the kernel tree into it (ensureUserdata in config.zig). After this, an upgrade +# cannot touch the registry at all — it no longer lives in the replaced tree. +if [ -x "$BINDIR/hkm-config" ]; then + say "Pinning configuration…" + # It exits non-zero when vendor/ is absent, which is the expected state after + # --no-composer — so only surface that as a problem when composer did run. + if ! "$BINDIR/hkm-config" check >/dev/null 2>&1; then + [ "$SKIP_COMPOSER" -eq 1 ] \ + || warn "hkm-config check reported problems — run '$BINDIR/hkm-config check' to see them." + fi +fi + +# ── verify ────────────────────────────────────────────────────────────────── +# The install itself has already succeeded. doctor's exit code reports the +# ENVIRONMENT, not the install, so it must not become this script's exit code. +printf '\n' +DOCTOR_OK=1 +if [ -x "$BINDIR/hkm" ]; then + say "Checking what the kernel still needs…" + "$BINDIR/hkm" doctor || DOCTOR_OK=0 +fi + +printf '\n' +ok "Installed for $(id -un) only; nothing was written outside your home." +if [ "$DOCTOR_OK" -eq 0 ]; then + printf ' Some requirements are not met yet — see "Must fix" above.\n' + printf ' Installing PHP and its extensions needs an administrator; everything\n' + printf ' else you can do yourself. Re-check any time with: hkm doctor\n' +fi +printf ' Kernel: %s\n' "$DEST" +printf ' Config: %s/hkm/config.env\n' "${XDG_CONFIG_HOME:-$HOME/.config}" +printf ' Data: %s/hkm\n' "${XDG_DATA_HOME:-$HOME/.local/share}" +printf ' Remove: %s --uninstall\n' "$0" diff --git a/tools/src/commands/doctor.zig b/tools/src/commands/doctor.zig index 9eb0957..41c2463 100644 --- a/tools/src/commands/doctor.zig +++ b/tools/src/commands/doctor.zig @@ -1,27 +1,97 @@ -//! `hkm doctor` — diagnose the local environment before install / first run. +//! `hkm doctor` — the single authority on "can this machine run the kernel?". //! -//! Verifies the machine can actually run a PhpServicePlatform project: -//! • a `php` binary is on PATH (or HKM_PHP_BIN) and is >= 8.4 -//! • every REQUIRED PHP extension is loaded -//! • reports OPTIONAL extensions (redis, swoole, pdo drivers …) as hints -//! • at least one PDO driver is present -//! • the kernel autoload is resolvable (packaged install or --dev checkout) +//! Installing is deliberately unconditional: `tools/install.sh` only copies +//! files into your home and never demands PHP, composer or an administrator. +//! That trade means SOMETHING has to tell you what is still missing, in one +//! place, with the command to fix each item. This is that something. //! -//! The extension/version checks are delegated to PHP itself (a `php -r` preflight -//! script) so they reflect the EXACT runtime a project will use — not a guess. -//! Exit code is 0 only when PHP is present, new enough, and no required -//! extension is missing; otherwise 1 (CI-friendly gate before `hkm run`). +//! It walks every requirement the kernel actually has, in the order they matter: +//! +//! Launcher this binary, whether its dir is on PATH, and whether another +//! `hkm` earlier on PATH would shadow it +//! Kernel the kernel root, its PHP CLI, the first-party modules/ +//! path-repositories, and vendor/autoload.php +//! Configuration ~/.config/hkm/config.env, a STALE HKM_KERNEL_HOME pin, the +//! userdata dir and the project registry +//! Tooling php, composer, git, node/npm — each with what needs it +//! PHP runtime version, required extensions, a PDO driver (asked of PHP +//! itself via a `php -r` preflight, so it reflects the exact +//! runtime a project will use rather than a guess) +//! +//! Exit code is 0 only when every HARD requirement passes, so it works as a CI +//! gate. Soft findings (no git, no node, PATH not set up) warn and do not fail. const std = @import("std"); +const builtin = @import("builtin"); const run_cmd = @import("run.zig"); const kernel = @import("../lib/kernel.zig"); const prompt = @import("../lib/prompt.zig"); +const userconfig = @import("../lib/userconfig.zig"); +const util = @import("../lib/util.zig"); const Io = std.Io; +const Dir = std.Io.Dir; const EnvMap = std.process.Environ.Map; -/// The PHP preflight. Kept as a single `-r` program so `hkm doctor` needs no -/// files on disk. Prints a human table and exits non-zero on a hard failure. +/// Accumulates findings so the run never stops at the first problem — someone +/// with three things missing should learn all three from one command. +const Report = struct { + hard: std.ArrayList([]const u8) = .empty, + soft: std.ArrayList([]const u8) = .empty, + allocator: std.mem.Allocator, + + fn fail(self: *Report, fix: []const u8) void { + self.hard.append(self.allocator, fix) catch {}; + } + fn hint(self: *Report, fix: []const u8) void { + self.soft.append(self.allocator, fix) catch {}; + } +}; + +const OK = "OK"; +const MISSING = "MISSING"; + +fn mark(present: bool) []const u8 { + return if (present) OK else MISSING; +} + +/// Locate an executable by walking PATH. Returns the FIRST match, which is the +/// one that would actually run. +fn findOnPath(allocator: std.mem.Allocator, io: Io, env: *EnvMap, name: []const u8) ?[]const u8 { + const path = env.get("PATH") orelse return null; + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |dir| { + if (dir.len == 0) continue; + const cand = std.fs.path.join(allocator, &.{ dir, name }) catch continue; + if (util.fileExists(io, cand)) return cand; + } + return null; +} + +/// Every match on PATH, in order — used to detect one install shadowing another. +fn countOnPath(allocator: std.mem.Allocator, io: Io, env: *EnvMap, name: []const u8) usize { + const path = env.get("PATH") orelse return 0; + var n: usize = 0; + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |dir| { + if (dir.len == 0) continue; + const cand = std.fs.path.join(allocator, &.{ dir, name }) catch continue; + if (util.fileExists(io, cand)) n += 1; + } + return n; +} + +fn dirOnPath(env: *EnvMap, dir: []const u8) bool { + const path = env.get("PATH") orelse return false; + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |entry| { + if (std.mem.eql(u8, util.trimSlash(entry), util.trimSlash(dir))) return true; + } + return false; +} + +/// The PHP preflight. A single `-r` program so `hkm doctor` needs no files on +/// disk. Prints a table and exits non-zero on a hard failure. const preflight = \\$reqPhp = '8.4.1'; \\$okPhp = version_compare(PHP_VERSION, $reqPhp, '>='); @@ -36,6 +106,8 @@ const preflight = \\$drivers = class_exists('PDO') ? PDO::getAvailableDrivers() : []; \\$hasDriver = (bool) array_intersect(['mysql','pgsql','sqlite','sqlsrv'], $drivers); \\printf(" pdo-driver %s (%s)\n", $hasDriver ? 'OK' : 'MISSING <-- need one', $drivers ? implode(',', $drivers) : 'none'); + \\$ml = ini_get('memory_limit'); + \\printf(" memory_limit %s\n", $ml === false ? 'unknown' : $ml); \\$optional = [ \\ 'redis' => 'RedisCache plugin (cache + queue)', \\ 'swoole' => 'OpenSwoole HTTP server (api face)', @@ -44,16 +116,13 @@ const preflight = \\ 'intl' => 'i18n / locale formatting', \\ 'zip' => 'archive support', \\ 'sodium' => 'modern crypto (recommended)', + \\ 'opcache' => 'production performance', \\]; \\echo "\n optional:\n"; \\foreach ($optional as $e => $why) { \\ printf(" ext-%-11s %-9s %s\n", $e, extension_loaded($e) ? 'present' : 'absent', $why); \\} - \\$hardFail = !$okPhp || $missing || !$hasDriver; - \\echo "\n"; - \\if ($hardFail) { echo " RESULT: FAIL — resolve the required items above.\n"; } - \\else { echo " RESULT: OK — environment satisfies the framework requirements.\n"; } - \\exit($hardFail ? 1 : 0); + \\exit((!$okPhp || $missing || !$hasDriver) ? 1 : 0); ; fn phpBin(allocator: std.mem.Allocator, env: *EnvMap) ![]const u8 { @@ -65,44 +134,196 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c _ = args; prompt.intro("hkm doctor"); + var rep = Report{ .allocator = allocator }; + const cwd = Dir.cwd(); + + // ── Platform ──────────────────────────────────────────────────────────── prompt.section("Platform"); - prompt.item("os", @tagName(@import("builtin").os.tag)); - prompt.item("arch", @tagName(@import("builtin").cpu.arch)); + prompt.item("os", @tagName(builtin.os.tag)); + prompt.item("arch", @tagName(builtin.cpu.arch)); + + // ── Launcher ──────────────────────────────────────────────────────────── + prompt.section("Launcher"); + var own_dir: ?[]const u8 = null; + if (std.process.executableDirPathAlloc(io, allocator)) |d| { + own_dir = d; + prompt.item("this binary", d); + if (dirOnPath(env, d)) { + prompt.item("on PATH", "yes"); + } else { + prompt.item("on PATH", "NO — `hkm` will not be found in a new shell"); + rep.hint(try std.fmt.allocPrint(allocator, "add to PATH: export PATH=\"{s}:$PATH\"", .{d})); + } + } else |_| { + prompt.item("this binary", "unknown"); + } + + // Two installs (a .deb in /usr/bin and a user install in ~/.local/bin) is a + // normal state, and the one that wins is decided by PATH order — silently. + const n_hkm = countOnPath(allocator, io, env, "hkm"); + if (n_hkm > 1) { + const first = findOnPath(allocator, io, env, "hkm") orelse "?"; + prompt.item("copies on PATH", try std.fmt.allocPrint(allocator, "{d} — first is {s}", .{ n_hkm, first })); + if (own_dir) |d| { + const own_exe = try std.fs.path.join(allocator, &.{ d, "hkm" }); + if (!std.mem.eql(u8, own_exe, first)) { + prompt.warn("another hkm earlier on PATH shadows this one."); + rep.hint("remove the system copy (sudo apt remove hkm-kernel) or reorder PATH"); + } + } + } - // Show WHERE the launcher will find the kernel PHP CLI, and whether it - // actually exists — the #1 thing to confirm on a portable/.app/zip install. + // ── Kernel ────────────────────────────────────────────────────────────── prompt.section("Kernel"); const k = try kernel.resolve(allocator, io, env); prompt.item("cli path", k.path); prompt.item("resolved via", kernel.sourceLabel(k.source)); - prompt.item("present", if (k.exists) "yes" else "NO — set HKM_KERNEL_HOME or reinstall"); + prompt.item("cli present", if (k.exists) OK else "MISSING — reinstall or set HKM_KERNEL_HOME"); + if (!k.exists) rep.fail("kernel CLI missing — reinstall, or: hkm-config set-kernel-home "); + + const home_opt = try kernel.resolveHome(allocator, io, env); + var vendor_ok = false; + if (home_opt) |home| { + prompt.item("kernel root", home); + + const composer_json = try std.fs.path.join(allocator, &.{ home, "composer.json" }); + const has_manifest = util.fileExists(io, composer_json); + prompt.item("composer.json", mark(has_manifest)); + if (!has_manifest) rep.fail("kernel root has no composer.json — the install is incomplete"); + + const src_dir = try std.fs.path.join(allocator, &.{ home, "src" }); + prompt.item("src/", mark(util.dirExists(cwd, io, src_dir))); + + // The first-party packages are composer PATH repositories. When they are + // absent, `composer install` fails outright rather than degrading — so + // this is worth naming individually. + const mods = [_][]const u8{ "bind-it", "php-io-cli", "let-migrate", "http" }; + var missing_mods: usize = 0; + for (mods) |m| { + const p = try std.fs.path.join(allocator, &.{ home, "modules", m, "composer.json" }); + if (!util.fileExists(io, p)) missing_mods += 1; + } + prompt.item("modules/ (4 first-party)", if (missing_mods == 0) + OK + else + try std.fmt.allocPrint(allocator, "{d} MISSING — composer install will fail", .{missing_mods})); + if (missing_mods > 0) { + rep.fail("first-party modules missing — reinstall the bundle, or: git submodule update --init"); + } + + const autoload = try std.fs.path.join(allocator, &.{ home, "vendor", "autoload.php" }); + vendor_ok = util.fileExists(io, autoload); + prompt.item("vendor/autoload.php", if (vendor_ok) OK else "MISSING — dependencies not installed"); + if (!vendor_ok) { + rep.fail(try std.fmt.allocPrint(allocator, "install dependencies: cd {s} && ./install.sh", .{home})); + } + + prompt.item("root writable", if (util.canWrite(io, home)) "yes" else "no — composer install will fail here"); + if (!util.canWrite(io, home)) { + rep.hint("kernel root is not writable by you (a root-owned /opt install?) — prefer a user install"); + } + } else { + prompt.item("kernel root", "NOT FOUND"); + rep.fail("no kernel found — install it, or: hkm-config set-kernel-home "); + } + + // ── Configuration ─────────────────────────────────────────────────────── + prompt.section("Configuration"); + if (try userconfig.path(allocator, env)) |cfg| { + prompt.item("config file", cfg); + prompt.item("exists", if (util.fileExists(io, cfg)) "yes" else "no (defaults in use)"); + } + + // A pin that points somewhere other than the resolved kernel is the quiet + // failure this check exists for: the launcher reads config.env BEFORE + // self-locating, so a stale pin keeps an OLD kernel in use after a reinstall. + if (try userconfig.get(allocator, io, env, "HKM_KERNEL_HOME")) |pin| { + const stale = if (home_opt) |h| !std.mem.eql(u8, util.trimSlash(pin), util.trimSlash(h)) else true; + prompt.item("HKM_KERNEL_HOME", pin); + if (stale) { + prompt.warn("pinned kernel differs from the one in use — the pin wins."); + rep.hint("repoint it: hkm-config set-kernel-home "); + } + } else { + prompt.item("HKM_KERNEL_HOME", "not pinned (self-locating)"); + } + + const userdata = try userconfig.get(allocator, io, env, "HKM_USERDATA_DIR"); + if (userdata) |ud| { + prompt.item("userdata dir", ud); + prompt.item("writable", if (util.canWrite(io, ud)) "yes" else "NO — the registry cannot be updated"); + if (!util.canWrite(io, ud)) rep.fail("userdata dir is not writable — check its ownership"); + const proj = try std.fs.path.join(allocator, &.{ ud, "projects.json" }); + prompt.item("projects.json", if (util.fileExists(io, proj)) OK else "absent (no projects registered yet)"); + } else { + prompt.item("userdata dir", "not pinned — run: hkm-config check"); + rep.hint("pin a persistent registry dir so upgrades cannot touch it: hkm-config check"); + } + + // ── Tooling ───────────────────────────────────────────────────────────── + prompt.section("Tooling"); const php = try phpBin(allocator, env); + const php_path = findOnPath(allocator, io, env, php); + prompt.item("php", php_path orelse "MISSING — required to run anything"); + if (php_path == null) { + rep.fail("install PHP >= 8.4 (Debian: sudo apt install php8.4-cli)"); + } - prompt.section("PHP runtime & extensions"); + const composer_path = findOnPath(allocator, io, env, "composer"); + prompt.item("composer", composer_path orelse "absent — needed only to build vendor/"); + if (composer_path == null and !vendor_ok) { + // The kernel's install.sh downloads composer.phar when composer is not + // installed, so this is a hint rather than a hard failure. + rep.hint("no composer — the kernel's install.sh will fetch composer.phar instead"); + } - // Run the preflight with stdout/stderr inherited so PHP prints the table. - var argv = [_][]const u8{ php, "-d", "display_errors=stderr", "-r", preflight }; - const code = run_cmd.spawnWait(io, env, &argv) catch |e| { - prompt.err("could not execute the PHP binary — is PHP installed and on PATH?"); - prompt.item("tried", php); - prompt.item("override", "set HKM_PHP_BIN=/full/path/to/php"); - prompt.blank(); - prompt.section("Install PHP >= 8.4"); - prompt.item("Debian/Ubuntu", "sudo apt install php8.4-cli php8.4-{mbstring,curl,pdo,mysql,xml}"); + prompt.item("git", findOnPath(allocator, io, env, "git") orelse "absent — needed by `hkm plugins` git sources"); + prompt.item("node", findOnPath(allocator, io, env, "node") orelse "absent — needed by `hkm ui` (frontend only)"); + prompt.item("npm", findOnPath(allocator, io, env, "npm") orelse "absent — needed by `hkm ui` (frontend only)"); + + // ── PHP runtime & extensions ──────────────────────────────────────────── + prompt.section("PHP runtime & extensions"); + if (php_path == null) { + prompt.warn("skipped — no php binary to ask."); + prompt.item("Debian/Ubuntu", "sudo apt install php8.4-cli php8.4-{mbstring,curl,xml,zip,mysql}"); prompt.item("macOS (brew)", "brew install php"); - prompt.item("Windows", "winget install PHP.PHP (or https://windows.php.net)"); - prompt.item("detail", @errorName(e)); - return 1; - }; + prompt.item("Windows", "winget install PHP.PHP"); + prompt.item("override", "set HKM_PHP_BIN=/full/path/to/php"); + } else { + var argv = [_][]const u8{ php, "-d", "display_errors=stderr", "-r", preflight }; + const code = run_cmd.spawnWait(io, env, &argv) catch |e| blk: { + prompt.err("could not execute the PHP binary."); + prompt.item("tried", php); + prompt.item("detail", @errorName(e)); + break :blk @as(u8, 1); + }; + if (code != 0) { + rep.fail("PHP runtime does not meet requirements — see the table above"); + } + } + // ── Verdict ───────────────────────────────────────────────────────────── prompt.blank(); - if (code == 0) { - prompt.ok("environment is ready — you can run `hkm run` / `hkm new`."); - } else { - prompt.warn("environment is INCOMPLETE — install the items marked required above."); - prompt.item("Debian/Ubuntu", "sudo apt install php8.4-{mbstring,curl,openssl,pdo,mysql,sqlite3}"); - prompt.item("macOS (brew)", "brew install php # bundles the common extensions"); + if (rep.hard.items.len == 0 and rep.soft.items.len == 0) { + prompt.ok("everything the kernel needs is present."); + return 0; + } + + if (rep.hard.items.len > 0) { + prompt.section("Must fix"); + for (rep.hard.items) |f| prompt.item("→", f); + } + if (rep.soft.items.len > 0) { + prompt.section("Worth fixing"); + for (rep.soft.items) |f| prompt.item("→", f); + } + + prompt.blank(); + if (rep.hard.items.len > 0) { + prompt.warn("environment is INCOMPLETE — the items under \"Must fix\" block the kernel."); + return 1; } - return code; + prompt.ok("environment is usable; the notes above are optional improvements."); + return 0; } From 824b1b5d3075e62f5f5e3c5c347d878b6843db13 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 22:40:10 +0300 Subject: [PATCH 134/140] feat(template): wire the scaffold up to @pageflow/admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pageflow v1.1.0 ships @pageflow/admin — an admin shell that deliberately owns no state of its own. Three things have to exist on the PROJECT side for that to work, and the scaffold now provides all three: - **A three-state theme.** `@providers/theme` exposes { theme, resolvedTheme, setTheme, toggle } and a "system" default that keeps following the OS if the OS setting changes mid-session. The shell's is only a control over this context, so two plugins can never fight over the app's appearance. - **Sidebar CSS variables.** A plugin cannot ship the variables its own components depend on and still be overridable per project, so they live in the project's theme.css. Restyle freely; keep the NAMES. - **A globbed nav registry.** Each plugin contributing to the sidebar ships ui/admin/nav.ts and registers at import time. Globbing them beats a hard-coded list in the registry, which every new plugin would have to edit. Both surfaces also wrap their tree in AppErrorBoundary: without one, a throw in any page component unmounts the whole app rather than the page that failed. --- .../templates/frontend/docs/HOW_IT_WORKS.md | 65 ++++++++++++- .../frontend/src/shared/providers/theme.tsx | 96 ++++++++++++++++--- .../frontend/src/shared/styles/theme.css | 60 ++++++++++++ .../frontend/src/surfaces/admin/index.tsx | 22 ++++- .../frontend/src/surfaces/project/index.tsx | 16 +++- 5 files changed, 237 insertions(+), 22 deletions(-) diff --git a/tools/src/templates/frontend/docs/HOW_IT_WORKS.md b/tools/src/templates/frontend/docs/HOW_IT_WORKS.md index 200f2a6..448c8b3 100644 --- a/tools/src/templates/frontend/docs/HOW_IT_WORKS.md +++ b/tools/src/templates/frontend/docs/HOW_IT_WORKS.md @@ -237,9 +237,72 @@ See `plugins/User/ui/README.md` for a complete worked example (admin list/detail | `Link` | `@pageflow/react` | in-app navigation (no full reload); `only`, `as`, `preserveScroll` | | `useForm` | `@pageflow/react` | forms with CSRF, `processing`, `errors` | | `router` | `@pageflow/react` | imperative visits / partial reloads (`only: [...]`) | +| `AdminLayout`, `useSetPageHeader`, `ResourceListShell`, `DataTable` | `@pageflow/admin` | the admin shell + kit (see below) | | `Button`, `Dialog`, … | `@ui/*` | the shared shadcn design system (49 components) | | `cn` | `@lib/utils` | Tailwind class merge | -| `useTheme`, `ThemeProvider` | `@providers/theme` | light/dark | +| `useTheme`, `ThemeProvider` | `@providers/theme` | light / dark / system | + +--- + +## The admin shell — `@pageflow/admin` + +A third Pageflow entry point (beside `core` and `react`) carrying the admin +shell, the navigation registry and the domain-free building blocks. Nothing in +`@pageflow/core` or `@pageflow/react` imports it, so a public-only surface never +bundles it. + +### Attach the layout as a PERSISTENT layout + +```tsx +import type { ReactNode } from "react"; +import { AdminLayout, useSetPageHeader } from "@pageflow/admin"; + +export default function Sales() { + useSetPageHeader({ title: "Sales", actions: [{ label: "Export", onClick: exportCsv }] }); + return
; +} + +Sales.layout = (page: ReactNode) => {page}; +``` + +Pageflow applies `Component.layout` **outside** the swapped page, so the shell +survives navigation — sidebar scroll, open menus and the nav overflow +calculation are all preserved. Wrapping the page's own return instead remounts +the entire sidebar on every click. + +`AdminLayout` takes no data props: it reads the reserved **`adminShell`** shared +prop (user, tenant, switchable tenants, feature flags, logout/account/settings +URLs). Share it once server-side and every page has it. + +`AuthLayout` is the nav-free equivalent for login / register / consent pages. + +### Contribute a sidebar section from a plugin + +Each plugin declares its own navigation in `ui/admin/nav.ts`; the admin surface +globs `/plugins/*/admin/nav.ts`, so the registry never names a business domain: + +```ts +import { Building2 } from "lucide-react"; +import { registerModule, registerFeature } from "@pageflow/admin"; + +registerFeature({ id: "rental", label: "Rental management" }); + +registerModule({ + id: "rental", + sectionLabel: "Rental", + order: 40, + features: ["rental"], // hidden unless proj.json enables it + items: [{ id: "properties", label: "Properties", icon: Building2, + path: "/admin/rental/properties" }], +}); +``` + +Visibility is driven by the server: `proj.json` `features[]` → +`DomainContext->features` → `adminShell.features`. A flag matching nothing logs a +warning naming it rather than silently doing nothing. + +Full reference — every export, the settings-tab registry, the list/table kit: +`plugins/hkm-plugin-pageflow/ui/admin/README.md`. Add more shadcn components with `npx shadcn add ` (writes into `src/shared/ui/`, driven by `components.json`). diff --git a/tools/src/templates/frontend/src/shared/providers/theme.tsx b/tools/src/templates/frontend/src/shared/providers/theme.tsx index e30a09b..0b9a23a 100644 --- a/tools/src/templates/frontend/src/shared/providers/theme.tsx +++ b/tools/src/templates/frontend/src/shared/providers/theme.tsx @@ -1,22 +1,92 @@ import * as React from "react"; -type Theme = "light" | "dark"; -const ThemeContext = React.createContext<{ theme: Theme; toggle: () => void }>({ - theme: "light", +/** + * Three-state theme: an explicit choice, or "system" (the default) which follows + * the OS and keeps following it if the OS setting changes mid-session. + * + * The theme belongs to the PROJECT, not to a plugin — `@pageflow/admin`'s + * `` is only a control over this context, so two plugins can never + * end up fighting over the app's appearance. + */ +export type Theme = "light" | "dark" | "system"; + +/** What is actually painted — "system" resolved against the OS preference. */ +export type ResolvedTheme = "light" | "dark"; + +interface ThemeContextValue { + /** The user's choice, including "system". */ + theme: Theme; + /** What that currently resolves to. */ + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; + /** Flip between light and dark. From "system" it flips away from the OS value. */ + toggle: () => void; +} + +const STORAGE_KEY = "theme"; + +const ThemeContext = React.createContext({ + theme: "system", + resolvedTheme: "light", + setTheme: () => {}, toggle: () => {}, }); -/** Minimal light/dark provider — swap for your real one as the app grows. */ -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = React.useState( - () => (localStorage.getItem("theme") as Theme) || "light", - ); +function systemTheme(): ResolvedTheme { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function storedTheme(): Theme { + if (typeof window === "undefined") return "system"; + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored === "light" || stored === "dark" || stored === "system" ? stored : "system"; +} + +export function ThemeProvider({ + children, + defaultTheme = "system", +}: { + children: React.ReactNode; + defaultTheme?: Theme; +}) { + const [theme, setThemeState] = React.useState(() => storedTheme() ?? defaultTheme); + const [systemPref, setSystemPref] = React.useState(systemTheme); + + // Keep following the OS while the choice is "system". + React.useEffect(() => { + const query = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = (event: MediaQueryListEvent) => + setSystemPref(event.matches ? "dark" : "light"); + + setSystemPref(query.matches ? "dark" : "light"); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, []); + + const resolvedTheme: ResolvedTheme = theme === "system" ? systemPref : theme; + React.useEffect(() => { - document.documentElement.classList.toggle("dark", theme === "dark"); - localStorage.setItem("theme", theme); - }, [theme]); - const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light")); - return {children}; + document.documentElement.classList.toggle("dark", resolvedTheme === "dark"); + document.documentElement.style.colorScheme = resolvedTheme; + }, [resolvedTheme]); + + const setTheme = React.useCallback((next: Theme) => { + setThemeState(next); + window.localStorage.setItem(STORAGE_KEY, next); + }, []); + + const value = React.useMemo( + () => ({ + theme, + resolvedTheme, + setTheme, + toggle: () => setTheme(resolvedTheme === "dark" ? "light" : "dark"), + }), + [theme, resolvedTheme, setTheme], + ); + + return {children}; } export const useTheme = () => React.useContext(ThemeContext); diff --git a/tools/src/templates/frontend/src/shared/styles/theme.css b/tools/src/templates/frontend/src/shared/styles/theme.css index 5fcf149..f8644a1 100644 --- a/tools/src/templates/frontend/src/shared/styles/theme.css +++ b/tools/src/templates/frontend/src/shared/styles/theme.css @@ -40,6 +40,35 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + + /* + * Admin sidebar. Consumed by @pageflow/admin's shell — a plugin cannot ship + * the CSS variables its own components depend on and still be overridable + * per project, so they live here. Restyle freely; keep the NAMES. + */ + --sidebar-bg: 0 0% 100%; + --sidebar-fg: 0 0% 30%; + --sidebar-fg-muted: 0 0% 55%; + --sidebar-fg-active: 221.2 83.2% 53.3%; + --sidebar-border: 214.3 31.8% 91.4%; + --sidebar-hover: 210 40% 96%; + --sidebar-active: 221.2 83.2% 53.3%; + --sidebar-section: 0 0% 45%; + --sidebar-width: 260px; + + /* + * Row metrics for the sidebar's overflow calculator. It decides how many nav + * rows fit WITHOUT measuring every one, so these must track the padding the + * shell actually renders. 0.3 hard-coded them in the TSX, where a padding + * change was a silent miscount; reading them from here means a project that + * restyles the sidebar can correct the arithmetic in the same place. + */ + --nav-row-h: 36px; + --nav-row-h-compact: 32px; + --nav-section-label-h: 28px; + --nav-section-label-h-compact: 24px; + --nav-section-gap: 16px; + --nav-section-gap-compact: 12px; } .dark { @@ -67,6 +96,15 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + + --sidebar-bg: 222.2 84% 4.9%; + --sidebar-fg: 215 20.2% 65.1%; + --sidebar-fg-muted: 215 20.2% 45%; + --sidebar-fg-active: 217.2 91.2% 59.8%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-hover: 217.2 32.6% 14%; + --sidebar-active: 217.2 91.2% 59.8%; + --sidebar-section: 215 20.2% 45%; } /* Map the HSL tokens onto Tailwind's color/radius scales (v4 `@theme inline`). */ @@ -96,6 +134,15 @@ --color-chart-4: hsl(var(--chart-4)); --color-chart-5: hsl(var(--chart-5)); + --color-sidebar-bg: hsl(var(--sidebar-bg)); + --color-sidebar-fg: hsl(var(--sidebar-fg)); + --color-sidebar-fg-muted: hsl(var(--sidebar-fg-muted)); + --color-sidebar-fg-active: hsl(var(--sidebar-fg-active)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-hover: hsl(var(--sidebar-hover)); + --color-sidebar-active: hsl(var(--sidebar-active)); + --color-sidebar-section: hsl(var(--sidebar-section)); + --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -110,4 +157,17 @@ background-color: hsl(var(--background)); color: hsl(var(--foreground)); } + + /* Sidebar rows animate colour only — never layout, which would fight the + overflow calculator's ResizeObserver. */ + .sidebar-transition { + transition-property: color, background-color, border-color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + } + @media (prefers-reduced-motion: reduce) { + .sidebar-transition { + transition: none; + } + } } diff --git a/tools/src/templates/frontend/src/surfaces/admin/index.tsx b/tools/src/templates/frontend/src/surfaces/admin/index.tsx index 02f020f..d6c071f 100644 --- a/tools/src/templates/frontend/src/surfaces/admin/index.tsx +++ b/tools/src/templates/frontend/src/surfaces/admin/index.tsx @@ -1,8 +1,17 @@ import "./styles/index.css"; import { createRoot } from "react-dom/client"; -import { createPageflowApp } from "@pageflow/react"; +import { createPageflowApp, AppErrorBoundary } from "@pageflow/react"; import { ThemeProvider } from "@providers/theme"; +// ── Admin navigation ──────────────────────────────────────────────────────── +// Each plugin that contributes to the sidebar ships `ui/admin/nav.ts`, which +// calls registerModule()/registerFeature() at import time. Globbing them here — +// rather than the registry importing a hard-coded list — is what keeps +// @pageflow/admin free of every business domain's name. The project's own +// nav files load LAST, so a project can unregister or re-order plugin modules. +import.meta.glob("/plugins/*/admin/nav.ts", { eager: true }); +import.meta.glob("./nav/*.ts", { eager: true }); + // ── Pageflow bootstrap ────────────────────────────────────────────────────── // The server (Plugins\Pageflow\Http\PageflowResponder) renders a page object // { component, props, url, version }. @pageflow/* is FEDERATED from the enabled @@ -52,10 +61,15 @@ createPageflowApp({ page: initialPage, resolve: resolveComponent, setup({ el, App, props }: { el: HTMLElement; App: any; props: any }) { + // The boundary is OUTSIDE the app: a throw in any page (including the + // "Page not found" resolveComponent raises) would otherwise unmount + // everything and leave a blank document. createRoot(el).render( - - - , + + + + + , ); }, progress: { delay: 0, color: "#6366f1" }, diff --git a/tools/src/templates/frontend/src/surfaces/project/index.tsx b/tools/src/templates/frontend/src/surfaces/project/index.tsx index 2da7024..1de4e2a 100644 --- a/tools/src/templates/frontend/src/surfaces/project/index.tsx +++ b/tools/src/templates/frontend/src/surfaces/project/index.tsx @@ -1,6 +1,6 @@ import "./styles/index.css"; import { createRoot, hydrateRoot } from "react-dom/client"; -import { createPageflowApp } from "@pageflow/react"; +import { createPageflowApp, AppErrorBoundary } from "@pageflow/react"; import { ThemeProvider } from "@providers/theme"; // ── Project (public) surface bootstrap ─────────────────────────────────────── @@ -48,10 +48,18 @@ createPageflowApp({ page: initialPage, resolve: resolveComponent, setup({ el, App, props }: { el: HTMLElement; App: any; props: any }) { + // The boundary is OUTSIDE the app: a throw in any page (including the + // "Page not found" resolveComponent raises) would otherwise unmount + // everything and leave a blank document — on the PUBLIC surface, to a + // visitor. It comes from @pageflow/react, not @pageflow/admin: it is + // dependency-free, and reaching for the admin entry would pull the whole + // shell into a marketing bundle. const tree = ( - - - + + + + + ); // Hydrate server-rendered HTML when present; otherwise mount fresh. if (el.hasChildNodes()) { From 99ffa449dbf0d46bc2c8579b591f5af93f2c6012 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 22:45:55 +0300 Subject: [PATCH 135/140] fix(release): pin php-io-cli back to its last loadable commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 655829006 merges two parallel implementations of unknown-option handling into src/AbstractCommand.php and keeps BOTH: `private array $unknownOptions` is declared twice (lines 50 and 78), with two incompatible row shapes (`spelling`/`key` populated at 482 and resolved by resolveUnknownOptions(), `token`/`name` populated at 158/520 and rejected by rejectUnknownOptions()). A duplicated property is a fatal at CLASS LOAD, so this is not one failing test — at that pointer every command built on AbstractCommand dies with "Cannot redeclare AlfacodeTeam\PhpIoCli\AbstractCommand::$unknownOptions". The kernel suite surfaces it as UnknownOptionTest ending the PHP process. Pinned back to 53620ec, the last commit where the class loads: 312 tests, 585 assertions green. Which of the two implementations is canonical is php-io-cli's call, so this reverts the POINTER only and changes nothing in that repo. --- modules/php-io-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/php-io-cli b/modules/php-io-cli index 6558290..53620ec 160000 --- a/modules/php-io-cli +++ b/modules/php-io-cli @@ -1 +1 @@ -Subproject commit 655829006ca95ff25841b025a72d398ecbe2f273 +Subproject commit 53620ec587ac8cf29ce82f239e072f566d73c79c From c92d55f72f32fc72274b5d53224332b42df6519c Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 22:47:13 +0300 Subject: [PATCH 136/140] chore: release 1.3.0 --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a02eed..f976e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-08-12 + +### Added +- **Domain lists.** `domain` / `subdomain` now take either a string or a LIST, + at all three levels — module-wide (`routeDomain` / `routeSubdomain`), group, + and route. A project serving several hosts can pin a group to "these three and + not that one" instead of duplicating the group per host. The domain is still + part of the route KEY, and a route grouped under a host the project does not + serve is still rejected at boot. +- **Plugin env seeding.** Enabling a plugin writes the environment it declares + in `module.json` `config[]` straight into `.env`, in three shapes: a documented + default is written ACTIVE, a required key with no default is written active but + EMPTY (so the boot failure points at a line you can see), and an optional key + with no default is written COMMENTED. Previously that list was discoverable + only from a boot stack trace, one variable per attempt. +- **A user-local install that needs no root.** Linux releases now ship a portable + tarball alongside the `.deb`; `tools/install.sh` unpacks kernel and launcher + entirely inside `$HOME` and writes nothing outside it. Published with the + release assets, so `curl … | sh` works without a checkout. The `.deb` remains + for multi-user machines and CI images. +- **Scaffold support for `@pageflow/admin`** (Pageflow v1.1.0): a three-state + theme provider (`{ theme, resolvedTheme, setTheme, toggle }` with a "system" + default that keeps following the OS), the sidebar CSS variables the shell + consumes, and a globbed `ui/admin/nav.ts` navigation registry. Both scaffold + surfaces now wrap their tree in `AppErrorBoundary`. + +### Fixed +- **`modules/php-io-cli` pinned back to its last loadable commit.** The newer + pointer merged two parallel implementations of unknown-option handling and kept + both, declaring `AbstractCommand::$unknownOptions` twice — a fatal at class + load, so every command built on `AbstractCommand` died, not just the test that + surfaced it. Only the pointer is reverted; which implementation is canonical is + php-io-cli's call. + +### Changed +- `hkm doctor` reports which install is actually in use, and whether a stale + `HKM_KERNEL_HOME` pin in `~/.config/hkm/config.env` is overriding it — the + failure that otherwise presents as "my changes do nothing". + ## [1.2.0] - 2026-08-12 ### Added From 1474f2dea73f4a537923e978cd76e55cea1604f8 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Wed, 12 Aug 2026 22:49:39 +0300 Subject: [PATCH 137/140] chore: release 1.3.1 --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f976e47..39cf398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.3.0] - 2026-08-12 +## [1.3.1] - 2026-08-12 + +Supersedes 1.3.0, which was tagged from a commit that never reached `master` +(the branch had advanced remotely between the build and the push). Tags are +immutable in this repository, so 1.3.0 was left in place rather than moved — +it builds, but it predates the `php-io-cli` pin below. **Use 1.3.1.** ### Added - **Domain lists.** `domain` / `subdomain` now take either a string or a LIST, From 1f6da00b4b526b9fe0438c9e8d43d11b495b37c4 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Thu, 13 Aug 2026 12:45:03 +0300 Subject: [PATCH 138/140] update --- docs/guides/00_SENTINEL_OVERVIEW.md | 2 +- docs/guides/11_PROJECT.md | 2 +- docs/guides/16_PLUGINS.md | 50 ++++++++++++++++++++----- docs/guides/17_PHP_IO_CLI.md | 2 +- docs/guides/Kernel-Guide_EN-FR.src.html | 29 +++++++++----- modules/php-io-cli | 2 +- 6 files changed, 64 insertions(+), 23 deletions(-) diff --git a/docs/guides/00_SENTINEL_OVERVIEW.md b/docs/guides/00_SENTINEL_OVERVIEW.md index 7bde2de..d732c9e 100644 --- a/docs/guides/00_SENTINEL_OVERVIEW.md +++ b/docs/guides/00_SENTINEL_OVERVIEW.md @@ -7,7 +7,7 @@ ## What HKM Kernel Is -HKM Kernel is a PHP 8.2+ framework built on the **Gated Demand Architecture (GDA)** pattern. +HKM Kernel is a PHP 8.4+ framework built on the **Gated Demand Architecture (GDA)** pattern. | Principle | Meaning | |---|---| diff --git a/docs/guides/11_PROJECT.md b/docs/guides/11_PROJECT.md index 8565136..791dfad 100644 --- a/docs/guides/11_PROJECT.md +++ b/docs/guides/11_PROJECT.md @@ -87,7 +87,7 @@ $builder = require __DIR__ . '/../../../app/bootstrap/base.php'; return $builder ->withModules([ - TaskModule::class, + InvoiceModule::class, ]) ->build(); ``` diff --git a/docs/guides/16_PLUGINS.md b/docs/guides/16_PLUGINS.md index c062e69..82d9bd6 100644 --- a/docs/guides/16_PLUGINS.md +++ b/docs/guides/16_PLUGINS.md @@ -1,8 +1,25 @@ # HKM Kernel — Plugins Layer -> The `plugins/` folder is the home for **locally developed business modules** that belong to -> this specific application but are not published as standalone packages. -> Every module here follows identical GDA rules — only the folder and namespace differ. +> **A plugin is a standalone package in its own git repository.** Since 1.1.0 the kernel +> depends on zero plugins and ships none: `plugins/` in the kernel repo is empty. In a +> PROJECT, `plugins/` holds the plugins that project has installed. +> Every plugin follows identical GDA rules — only the folder and namespace differ. + +**Repo:** `github.com/AlfaCode-Team/hkm-plugin-` · **Package:** +`alfacode-team/hkm-plugin-` · **Namespace:** `Plugins\{Name}\` · **Test doubles:** +`AlfaCode-Team/hkm-test-support` + +Slug = lower-cased folder name, except `DevTools→dev-tools`, `HttpClient→http-client`, +`RedisCache→redis-cache`, `SecurityFilters→security-filters`, `SocialAuth→social-auth`, +and the unhyphenated `SiteSEO→siteseo`, `ViteManifest→vitemanifest`, `OAuth2→oauth2`. + +**Managed with `hkm plugins`** — `install`, `enable` (auto-installs), `disable`, +`uninstall`, `versions`, `outdated`, `update`, `lock`, `verify`, `store`, `domains`, +`create`. Installs resolve to a TAG, never a branch; `plugins.lock.json` records remote, +tag, commit and kernel version (commit it, never hand-edit). A global plugin store keyed +`/-` shares one download across projects. `module.json` +`"kernel": "^1.2"` gates install. A dependency is a DOMAIN, not a repo name — 13 of 28 +domains do not match their repo name, so use `hkm plugins domains` rather than guessing. --- @@ -86,12 +103,12 @@ Add the `Provider` class to the appropriate project bootstrap: ```php // projects/admin/bootstrap/app.php -use Plugins\Task\Provider as TaskModule; +use Plugins\Invoice\Provider as InvoiceModule; use Plugins\MyOtherModule\Provider as MyOtherModule; return $builder ->withModules([ - TaskModule::class, + InvoiceModule::class, MyOtherModule::class, ]) ->build(); @@ -101,9 +118,20 @@ return $builder ## Registered Plugins -| Plugin | Namespace | Solves | Routes | -|---|---|---|---| -| Task | `Plugins\Task\` | `task.management` | `GET/POST /api/tasks`, `GET/POST/DELETE /api/tasks/{id}` | +| Plugin | Namespace | Solves | +|---|---|---| +| SiteSEO | `Plugins\SiteSEO\` | `seo.management` | +| I18n | `Plugins\I18n\` | `i18n.translation` | +| Logger | `Plugins\Logger\` | `logging.application` | +| Crypto | `Plugins\Crypto\` | `crypto.services` | +| Database | `Plugins\Database\` | `database.management` | +| Authorization | `Plugins\Authorization\` | `authorization.policy` | +| Audit | `Plugins\Audit\` | `audit.trail` | +| Settings | `Plugins\Settings\` | `tenant.settings` | +| SocialAuth | `Plugins\SocialAuth\` | `auth.social` | +| Commands | `Plugins\Commands\` | `system.commands` | +| Edge | `Plugins\Edge\` | `edge.routing` | +| DevTools | `Plugins\DevTools\` | `dev.tooling` | Infrastructure plugins (port adapters / pipeline stages, no routes) — see [20_FIRST_PARTY_PLUGINS.md](20_FIRST_PARTY_PLUGINS.md) for the full list and the @@ -129,7 +157,7 @@ project's `proj.json` `views` into `view-manifest.php`, which the View plugin's renderer consumes. ```jsonc -// plugins/Task/module.json +// {Invoice plugin}/module.json "views": "resources/views" // namespace defaults to "task" "views": { "path": "resources/views", "namespace": "task", "priority": 100, "global": true } // explicit form @@ -161,7 +189,9 @@ The resource-resolution model (project-over-plugin, deterministic at boot) is de ✓ module.json handlers use fully-qualified Plugins\... class strings ✓ Provider registered in projects/{project}/bootstrap/app.php ✗ Do NOT place plugin files under projects/ — that folder is for wiring only -✗ Do NOT add plugins as Composer path repositories — Plugins\ PSR-4 covers autoloading +✗ Do NOT author plugin source in the KERNEL repo's plugins/ — it ships no plugins +✗ Do NOT add a hkm-plugin-* require to the kernel's composer.json +✗ Do NOT hand-edit plugins.lock.json, or guess a plugin's repo from its solves domain ✗ All GDA five-layer access rules apply exactly as for any other module ``` diff --git a/docs/guides/17_PHP_IO_CLI.md b/docs/guides/17_PHP_IO_CLI.md index a34cd96..cdde39d 100644 --- a/docs/guides/17_PHP_IO_CLI.md +++ b/docs/guides/17_PHP_IO_CLI.md @@ -219,7 +219,7 @@ Add to your **project's** `composer.json` (not the library's): "php-io-cli": { "commands": [ "App\\Commands\\MigrateCommand", - "Plugins\\Task\\Infrastructure\\Commands\\TaskListCommand" + "Plugins\\Invoice\\Infrastructure\\Commands\\InvoiceListCommand" ] } } diff --git a/docs/guides/Kernel-Guide_EN-FR.src.html b/docs/guides/Kernel-Guide_EN-FR.src.html index 6c74214..cb502d3 100644 --- a/docs/guides/Kernel-Guide_EN-FR.src.html +++ b/docs/guides/Kernel-Guide_EN-FR.src.html @@ -67,7 +67,7 @@

The Kernel Guide

How the Gated Demand Architecture kernel works, how to use it,
and why it differs from other frameworks
· Guide bilingue — English & Français ·

- PHP 8.2+Gated Demand ArchitectureContributorsApp BuildersStep-by-stepEN / FR + PHP 8.4+Gated Demand ArchitectureContributorsApp BuildersStep-by-stepEN / FR
Package: alfacode-team/php-service-platform  ·  Namespace: AlfacodeTeam\PhpServicePlatform\Kernel\
@@ -243,7 +243,7 @@

4.3 Entry points

$kernel->http()->handle($request)->send();

4.4 TUTORIAL — Build a module from scratch (8 steps)

-

We rebuild the real Task plugin. It lives in plugins/Task/ under the Plugins\Task\ namespace and owns one domain: task.management.

+

We build a worked example plugin, Task, under the Plugins\Task\ namespace, owning one domain: task.management. Note: first-party plugins are not part of the kernel repository — each lives in its own repo (AlfaCode-Team/hkm-plugin-<slug>) and is installed into a project with hkm plugins install. Scaffold your own with hkm plugins create Task.

Step 1 — Declare the module in module.json (single source of truth)

plugins/Task/module.json
@@ -524,9 +524,14 @@

Adding a boot validation stage

5.6 Security gateway internals

-
Request → FirewallLayer → RateLimiterLayer → CsrfTokenLayer → [Auth layer] → pipeline
-              ↓ deny(403)      ↓ deny(429)        ↓ deny(403)      ↓ deny(401)
-          ZERO module cost at every denial
+
Request → CsrfTokenLayer → [Auth plugin: JwtAuthLayer] → [PersonalAccessTokenLayer] → pipeline
+               ↓ deny(403)            ↓ deny(401)                    ↓ deny(401)
+           ZERO module cost at every denial
+

The kernel ships exactly one layer: CsrfTokenLayer. There is no kernel +FirewallLayer and no kernel RateLimiterLayer. Token authentication comes from the +Auth plugin; rate limiting (throttle) and IP filtering (shield) are +SecurityFilters route filters, which run inside the pipeline once a route opts in — they need a +CachePort and config, so they cannot be pre-module layers.

A layer implements check(Request): SecurityVerdict and never throws — it returns allow() or deny(code, reason). Layers are ordered cheapest-first so the cheapest rejection happens earliest. Authorization (role/permission checks) belongs in the Service layer, not the gateway. The kernel ships no JWT validator — token auth is provided by a project's Auth module as a layer hook registered in boot().

6. The Five Access Rules & Exception Hierarchy

@@ -723,7 +728,7 @@

4.3 Points d'entrée

$kernel->requestTeardown() // après chaque requête sous OpenSwoole

4.4 TUTORIEL — créer un module de zéro (8 étapes)

-

Nous reconstruisons le plugin réel Task, dans plugins/Task/ sous l'espace de noms Plugins\Task\, possédant le domaine task.management.

+

Nous construisons un plugin d'exemple, Task, sous l'espace de noms Plugins\Task\, possédant le domaine task.management. Note : les plugins de première partie ne font pas partie du dépôt du kernel — chacun possède son propre dépôt (AlfaCode-Team/hkm-plugin-<slug>) et s'installe dans un projet avec hkm plugins install. Créez le vôtre avec hkm plugins create Task.

Étape 1 — Déclarer dans module.json (source unique de vérité)

{
@@ -926,9 +931,15 @@ 

Ajouter une étape de validation au démarrage

5.6 Intérieur de la passerelle de sécurité

-
Requête → FirewallLayer → RateLimiterLayer → CsrfTokenLayer → [couche Auth] → pipeline
-               ↓ deny(403)     ↓ deny(429)        ↓ deny(403)      ↓ deny(401)
-           COÛT MODULE NUL à chaque refus
+
Requête → CsrfTokenLayer → [plugin Auth : JwtAuthLayer] → [PersonalAccessTokenLayer] → pipeline
+                ↓ deny(403)              ↓ deny(401)                     ↓ deny(401)
+            COÛT MODULE NUL à chaque refus
+

Le kernel ne fournit qu'une seule couche : CsrfTokenLayer. Il n'existe pas de +FirewallLayer ni de RateLimiterLayer dans le kernel. L'authentification par jeton provient +du plugin Auth ; la limitation de débit (throttle) et le filtrage d'IP +(shield) sont des filtres de route SecurityFilters, exécutés dans le pipeline lorsqu'une +route les déclare — ils nécessitent un CachePort et de la configuration, donc ils ne peuvent pas être des +couches pré-module.

Une couche implémente check(Request): SecurityVerdict et ne lève jamais. L'autorisation (rôles/permissions) appartient à la couche Service. Le kernel ne fournit aucun validateur JWT — l'auth par jeton vient du module Auth du projet, sous forme de hook enregistré dans boot().

6. Les cinq règles d'accès et la hiérarchie d'exceptions

diff --git a/modules/php-io-cli b/modules/php-io-cli index 53620ec..04147c7 160000 --- a/modules/php-io-cli +++ b/modules/php-io-cli @@ -1 +1 @@ -Subproject commit 53620ec587ac8cf29ce82f239e072f566d73c79c +Subproject commit 04147c7465efeaf5818eb0d192826040bf25b656 From 34abb2c78e998ea218b9075037c7aede6f7d0936 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Fri, 14 Aug 2026 18:11:36 +0300 Subject: [PATCH 139/140] feat: initialize projects configuration with shop, hkmcode, and hkmvote details --- README.md | 43 +- docs/guides/00_SENTINEL_OVERVIEW.md | 1 - docs/guides/07_CONTROLLER.md | 53 +- docs/guides/09_SECURITY.md | 81 +- docs/guides/11_PROJECT.md | 357 --------- docs/guides/16_PLUGINS.md | 82 +- docs/guides/19_DATABASE.md | 355 --------- docs/guides/20_FIRST_PARTY_PLUGINS.md | 586 -------------- docs/guides/21_CSRF.md | 2 +- docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md | 3 +- docs/guides/23_TENANCY.md | 250 ------ docs/guides/24_USER.md | 191 ----- docs/guides/25_AUTH.md | 324 -------- docs/guides/26_OAUTH2.md | 118 --- docs/guides/27_ENTITY_SUPPORT.md | 217 ------ docs/guides/30_ROUTING_COOKBOOK.md | 2 +- docs/guides/README.md | 65 +- modules/php-io-cli | 2 +- projects/Bootstrap/README.md | 191 ----- projects/Http/Controllers/README.md | 177 ----- projects/Infrastructure/README.md | 169 ---- projects/README.md | 126 +-- projects/Support/Casting/README.md | 481 ------------ projects/Support/Entity/README.md | 821 -------------------- projects/Support/README.md | 235 ------ projects/Support/Seo/README.md | 267 ------- 26 files changed, 180 insertions(+), 5019 deletions(-) delete mode 100644 docs/guides/11_PROJECT.md delete mode 100644 docs/guides/19_DATABASE.md delete mode 100644 docs/guides/20_FIRST_PARTY_PLUGINS.md delete mode 100644 docs/guides/23_TENANCY.md delete mode 100644 docs/guides/24_USER.md delete mode 100644 docs/guides/25_AUTH.md delete mode 100644 docs/guides/26_OAUTH2.md delete mode 100644 docs/guides/27_ENTITY_SUPPORT.md delete mode 100644 projects/Bootstrap/README.md delete mode 100644 projects/Http/Controllers/README.md delete mode 100644 projects/Infrastructure/README.md delete mode 100644 projects/Support/Casting/README.md delete mode 100644 projects/Support/Entity/README.md delete mode 100644 projects/Support/README.md delete mode 100644 projects/Support/Seo/README.md diff --git a/README.md b/README.md index 43ebd8c..07b5ffc 100644 --- a/README.md +++ b/README.md @@ -537,29 +537,27 @@ Domain → NOTHING EXTERNAL (zero imports outside Domain/) ## Batteries included (plugins) -Drop-in modules under `plugins/`, activated per project: - -| Plugin | Domain | What you get | -|---|---|---| -| **Auth** | `auth.identity` | JWT / PAT / session issuance + verification, refresh-token rotation, guards | -| **OAuth2** | `oauth.server` | Native OAuth 2.1 + OIDC server (auth code + PKCE, device code, JWKS, introspection) | -| **User** | `user.management` | Central identity store, email verification, transactional outbox, audit log | -| **Tenancy** | `tenancy.routing` | Multi-tenant DB routing, memberships, invitations, per-tenant isolation | -| **Validation** | `validation.rules` | Request validation engine + `AbstractDto` (`rules()`), ~45 built-in rules | -| **Mail** | `mail.delivery` | Native dependency-free mailer — SMTP/Sendmail/`mail()`, DKIM, attachments | -| **Storage** | `storage.local` | `StoragePort` over local disk **or** S3 (Flysystem), signed temp URLs | -| **Session / Cookie** | `session.management` / `http.cookies` | Encrypted sessions, flash, CSRF; queued encrypted cookies | -| **HttpClient** | `http.client` | `HttpClientPort` (cURL) with idempotent-safe retries + coroutine backoff | -| **View / ViteManifest / Pageflow** | frontend | PHP templating, Vite asset resolution, Inertia-style SPA bridge | -| **SecurityFilters** | `http.security_filters` | CORS + secure headers; route-filter aliases `auth`, `throttle`, `hmac`, `shield` | -| **I18n** | `i18n.translation` | File-based translator, pluralization, `Accept-Language` negotiation | - -Each plugin ships its own `README.md` — e.g. [Auth](plugins/Auth/README.md), -[Tenancy](plugins/Tenancy/README.md), [User](plugins/User/README.md), -[OAuth2](plugins/OAuth2/README.md). +The kernel ships **no** plugins and depends on none. Roughly thirty first-party +plugins cover auth, users, tenancy, OAuth2, mail, storage, sessions, validation, +i18n, templating and the SPA bridge — each its own package, owning exactly one +domain. + +```bash +hkm plugins domains # every plugin and the `solves` domain it claims +hkm plugins enable auth # install it, publish its assets, run its migrations +``` + +Then add its `Provider` to the project bootstrap's `withModules([...])`. + +**Each plugin documents itself in its own repository** — `README.md` for what it +is, `CLAUDE.md` for its contract and configuration, and `module.json` as the +authoritative `requires[]` / `exposes[]` / `config[]`. This repository keeps no +plugin catalogue: a static list here is the copy that goes stale, and it did. +Repositories are at `github.com/AlfaCode-Team/hkm-plugin-`. --- + ## Development from source ```bash @@ -608,7 +606,10 @@ Notes: - You can still cut a release manually at any time by pushing a `v*` tag. For deep dives, see the layer guides in [`docs/guides/`](docs/guides/) and the -[CHANGELOG](CHANGELOG.md). +[CHANGELOG](CHANGELOG.md). Those guides cover the kernel (`src/`) and the packages +it runs on (`modules/`); the `Project\` layer is documented in +[hkm-project-layer](https://github.com/AlfaCode-Team/hkm-project-layer), each +plugin in its own repository, and the `hkm` CLI in [`tools/`](tools/README.md). --- diff --git a/docs/guides/00_SENTINEL_OVERVIEW.md b/docs/guides/00_SENTINEL_OVERVIEW.md index d732c9e..23549e5 100644 --- a/docs/guides/00_SENTINEL_OVERVIEW.md +++ b/docs/guides/00_SENTINEL_OVERVIEW.md @@ -200,5 +200,4 @@ Always throw the exception type matching the layer. Never let a `\PDOException` | `08_EVENTS.md` | Domain vs Integration events, EventBus, outbox | | `09_SECURITY.md` | SecurityGateway, layers, Identity, tokens | | `10_TESTING.md` | Test patterns, fakes, port doubles, strategies | -| `11_PROJECT.md` | Bootstrap, port adapters, configuration wiring | | `12_WORKER.md` | Worker pipeline, jobs, retry, dead-letter queue | diff --git a/docs/guides/07_CONTROLLER.md b/docs/guides/07_CONTROLLER.md index 48ba1dd..a0d29d9 100644 --- a/docs/guides/07_CONTROLLER.md +++ b/docs/guides/07_CONTROLLER.md @@ -253,53 +253,44 @@ public function upload(Request $request): Response --- -## Base Controllers (project layer — `Project\Http\Controllers\`) +## `RequestAware` — the kernel's only controller seam -Two optional base classes live in `projects/Http/Controllers/` (namespace -`Project\`). They are project-layer, NOT kernel, because view rendering and -cookies are plugin concerns — the kernel stays renderer-agnostic. +The kernel is renderer-agnostic and knows nothing about controller base classes. +The single seam between the two is one interface: -| Base | Use for | Coupling | -|---|---|---| -| `ApiController` | JSON endpoints | Pure kernel types (no plugin) | -| `ViewController` | HTML/view endpoints | Injects `ViewRendererContract` (View plugin) | - -`ApiController` helpers: `ok()`, `created()`, `accepted()`, `noContent()`, -`paginated()`, `okOrNotFound()`, `notFound()`, `forbidden()`, `unprocessable()`, -`identity()`. `ViewController` helpers: `view()`, `viewNotFound()`, `redirect()`, -`back()`. - -Both `use InteractsWithCookies` (trait wrapping every public `CookieJar` method: -`cookie()`, `queueCookie()`, `rememberCookie()`, `forgetCookie()`, -`hasQueuedCookie()`, `decryptCookie()`, `cookieJar()`). - -### RequestAware — actions take route params ONLY (no `$request`) +```php +AlfacodeTeam\PhpServicePlatform\Kernel\Http\Contracts\RequestAware + public function setRequest(Request $request): static; +``` -Both bases implement the kernel contract -`AlfacodeTeam\…\Kernel\Http\Contracts\RequestAware` (`setRequest(Request): static`). -`ExecuteStage` detects it and: +`ExecuteStage` checks `instanceof RequestAware` and, when true: - calls `setRequest($request)` with the container-bearing request BEFORE the action, then -- invokes the action as `$method(...$routeParams)` — WITHOUT `$request`. +- invokes the action as `$method(...$routeParams)` — **without** `$request`. Plain controllers (not `RequestAware`) keep the conventional `$method($request, ...$params)` signature — fully backward compatible. ```php -use Project\Http\Controllers\ApiController; - -final class CartController extends ApiController // RequestAware +final class CartController implements RequestAware // route params only { - public function show(string $id): Response // route param only — no $request + public function show(string $id): Response { - $this->queueCookie('last_viewed', $id); // request injected by the kernel - return $this->okOrNotFound($this->cart->find($id)?->toArray()); + return Response::json($this->cart->find($id)?->toArray() ?? [], 200); } } ``` -The raw request is still available inside the action as `$this->request`; any -cookie helper also accepts an explicit `?Request` override. +``` +✗ Adding $request to a RequestAware action — it receives route params only +✗ Coupling the kernel to a controller base class or a view renderer — this + interface is the whole contract +``` + +Optional base classes (`ApiController`, `ViewController`) and their concern +traits are **project layer**, not kernel: view rendering and cookies are plugin +concerns. They are documented in +[hkm-project-layer](https://github.com/AlfaCode-Team/hkm-project-layer/blob/main/Http/Controllers/README.md). --- diff --git a/docs/guides/09_SECURITY.md b/docs/guides/09_SECURITY.md index 527293a..9350d5f 100644 --- a/docs/guides/09_SECURITY.md +++ b/docs/guides/09_SECURITY.md @@ -157,47 +157,44 @@ new CsrfTokenLayer( ); ``` -### Auth plugin layers — `JwtAuthLayer` / `PersonalAccessTokenLayer` - -```php -// Provided by Plugins\Auth (the kernel ships NO JWT code). You add them to -// withSecurity([...]) alongside CsrfTokenLayer. -// JwtAuthLayer — verifies a Bearer JWT (iss/aud/exp, jti deny-list), -// builds Identity from claims (incl. the `tnt` tenant claim). -// PersonalAccessTokenLayer — verifies long-lived personal access tokens. -// Session-based auth is a separate after.load stage (SessionAuthStage), not a gateway layer. -// All signature/token comparisons are timing-safe (hash_equals()). -``` - -### Tenant context on the Identity (`tnt` claim — multi-tenant control plane) - -`Identity.tenantId` carries the authenticated tenant for database-per-tenant -routing. `Plugins\Auth\Security\JwtAuthLayer` reads it from the signed **`tnt`** -claim (legacy `tenant` accepted for BC) and defaults it to **`''` (empty)**: - -```php -$tenant = (string) ($claims['tnt'] ?? $claims['tenant'] ?? ''); -$identity = new Identity(userId: $claims['sub'], tenantId: $tenant, /* … */); -``` - -- **Empty tenant claim ≠ central access.** `AuthService::issueJwt()` mints NO - tenant at login — but `TenantContextStage` routes STRICTLY: with no tenant - claim, the remembered cookie hint and then the Host identifier must still - resolve one, or the request 404s (no unscoped passthrough). Login/picker/public - pages therefore live on a host that is itself assigned to a tenant; - control-plane reads pin the central connection explicitly. -- **Non-empty tenant** is routed to its isolated database by - `Plugins\Tenancy`'s `TenantContextStage` (hooked `after.load`), which rebinds - `DatabasePort` in the request container. Mint a tenant-scoped token ONLY after - the user selects a tenant and membership is verified against the central - `user_tenants` table; re-check membership each request so a revoked seat loses - access before the token expires. -- **Control-plane plugins pin to central.** `Plugins\User` (the global `users` - identity table) and `Plugins\Auth` (`personal_access_tokens`) resolve the - `DatabaseConnectionManagerContract` **default** connection, NOT the per-request - (tenant-rebound) `DatabasePort` — so identity I/O never lands in a tenant DB. - Because the `tnt` claim is signed it cannot be forged, but it is still a hint, - not authority: authorization keys on `(userId, tenantId, role/permission)`. +### Token verification is a plugin's job + +**The kernel ships no JWT, API-key or session token validator, deliberately.** It +defines `SecurityLayerContract` and runs whatever layers a project passes to +`withSecurity([...])`; an auth plugin supplies the verifiers. Which layers exist, +what claims they read and how they are configured is that plugin's documentation, +not the kernel's. + +What the kernel guarantees regardless of the plugin: a layer **never throws** (it +returns a verdict), a missing credential means **anonymous rather than denied** +(public routes keep working), and a denial costs **zero module loading**. + +### Tenant context on the Identity (`tenantId`) + +`Identity.tenantId` is the only multi-tenancy the KERNEL knows about: an +immutable string it carries and hands to whatever runs next. The kernel does not +resolve tenants, own a registry, or know what a tenant database is — an auth +plugin populates the field, and a tenancy plugin acts on it. + +Three rules bind every consumer of that field, and they are kernel-level +guarantees rather than any one plugin's behaviour: + +- **The tenant id is a HINT, not authority.** Whatever set it — a signed claim, a + cookie, a host label — authorization still keys on + `(userId, tenantId, role/permission)`, re-checked against the store that owns + memberships. A signature proves the value survived transit unmodified; it does + not prove the seat still exists. +- **Empty is not "central access".** An absent tenant means *unresolved*, not + *privileged*. Any component that treats a missing tenant as permission to read + a shared/central store must say so explicitly and pin that connection itself. +- **A per-request rebind goes in the request-scoped container.** A plugin that + rebinds `DatabasePort` for a tenant binds it into the `ModuleContainer` + (discarded on `reset()`), never `CoreContainer` and never a static. Under + OpenSwoole a leaked binding means one tenant's request served from another + tenant's database. + +How a tenant is identified, routed, provisioned and revoked belongs to the +Tenancy, Auth and User plugins, and is documented in their repositories. --- @@ -247,7 +244,7 @@ $kernel->withSecurity([ ``` Rate limiting and IP filtering are not added here — a route opts into them with the -SecurityFilters `throttle` / `shield` filters (see `20_FIRST_PARTY_PLUGINS.md`). +SecurityFilters `throttle` / `shield` route filters (see the [SecurityFilters plugin](https://github.com/AlfaCode-Team/hkm-plugin-security-filters)). --- diff --git a/docs/guides/11_PROJECT.md b/docs/guides/11_PROJECT.md deleted file mode 100644 index 791dfad..0000000 --- a/docs/guides/11_PROJECT.md +++ /dev/null @@ -1,357 +0,0 @@ -# HKM Kernel — Project Layer - -> The Project layer contains no business logic. It wires kernel contracts to infrastructure adapters and chooses which business modules are active per project. - ---- - -## Current Project Bootstrap Architecture - -The repository now uses inheritance-safe project bootstrapping: - -- Shared base builder: `app/bootstrap/base.php` (returns an unbuilt `Kernel` builder) -- Per-project bootstrap: `projects/{project}/bootstrap/app.php` (extends base and calls `->build()`) -- Backward-compatible shim: `bootstrap/app.php` delegates to `projects/admin/bootstrap/app.php` -- Runtime selection: entry points resolve `HKM_PROJECT` (default: `admin`) and load `projects/{HKM_PROJECT}/bootstrap/app.php`, falling back to `bootstrap/app.php` - ---- - -## Why This Shape - -The kernel freezes `CoreContainer` when it materializes (the first entry-point call), not in `build()`. Inherited projects must still share the builder, not a built kernel instance — each project finalizes its own ports/modules with its own `->build()`. - -This allows: - -- one shared admin base in `app/` -- many child projects with their own module sets -- identical entry points reused across projects - ---- - -## Builder Semantics (Inheritance-Safe) - -`Kernel` builder methods are additive so child projects can extend base config safely: - -- `withPorts([...])`: merges with existing bindings (later keys override earlier ones) -- `withSecurity([...])`: appends layers (base first, project additions later) -- `withModules([...])`: appends and de-duplicates module class names preserving order - ---- - -## File Layout (As Implemented) - -```text -app/ -├── Infrastructure/ -│ ├── InMemoryCache.php -│ └── PdoDatabase.php -├── bootstrap/ -│ └── base.php -├── api/server.php -├── cli/run.php -├── worker/run.php -└── public_html/index.php - -projects/ -└── admin/ - └── bootstrap/app.php - -bootstrap/ -└── app.php # legacy shim -``` - ---- - -## Base Builder Pattern - -```php -// app/bootstrap/base.php (shared defaults, NO ->build()) -return Kernel::configure() - ->withBasePath(dirname(__DIR__, 2)) - ->withPorts([ - DatabasePort::class => new PdoDatabase(...), - CachePort::class => new InMemoryCache(), - ]) - ->withSecurity([ - new CsrfTokenLayer(exemptPaths: ['/api']), - ]); -``` - ---- - -## Project Bootstrap Pattern - -```php -// projects/admin/bootstrap/app.php -/** @var Kernel $builder */ -$builder = require __DIR__ . '/../../../app/bootstrap/base.php'; - -return $builder - ->withModules([ - InvoiceModule::class, - ]) - ->build(); -``` - ---- - -## Entry Point Resolution Pattern - -All entry points in `app/` follow this runtime bootstrap selection logic. Note the -fixed order: resolve the project, **load the environment, install the error net, THEN -require the kernel bootstrap** (so a pre-kernel failure is caught and cannot leak): - -```php -$rootPath = dirname(__DIR__, 2); -$domain = EntryHelpers::resolveDomain($rootPath, $host); // HTTP only; null in CLI/worker -$project = (string) (getenv('HKM_PROJECT') ?: 'admin'); // ← legitimate pre-env getenv - -LoadEnvironment::load($rootPath, $domain, $argv); // 1. .env cascade → $_ENV -ErrorGuard::install($rootPath . '/projects/' . $project . '/var/logs/errors.log'); // 2. error net - -$kernel = require EntryHelpers::bootstrapPathFor($rootPath, $project); // 3. kernel -``` - -`HKM_PROJECT` is read with `getenv()` on purpose — it selects which project to boot and -is a genuine OS/server variable evaluated *before* `LoadEnvironment` runs. Everything the -kernel and modules read afterwards must use the `env()` helper, not `getenv()` (see -`app/Bootstrap/Environment/`). - -Applied to: - -- `app/api/server.php` (env + guard installed once per worker in `workerStart`; guard is ini-only) -- `app/cli/run.php` -- `app/worker/run.php` -- `app/public_html/index.php` - ---- - -## Project Routes & Views (Project-Over-Plugin Priority) - -A project can declare its OWN routes and view paths — they take precedence over -plugin resources by default (deterministic, compiled at boot). - -```jsonc -// projects//proj.json (or the flat project-root proj.json) -{ - "name": "shop", - "views": "resources", // project view root (priority 0) - - // Hosts this project serves. DomainResolver matches an incoming Host against - // these, AND the route compiler validates every route "domain" against them. - "domains": ["shop.local", "app.shop.local"], - - "routes": [ - { "method": "GET", "path": "/", "handler": "Shop\\Http\\HomeController@index", "name": "home" }, - { "method": "GET", "path": "/ping", "handler": "Shop\\Http\\HomeController@ping" } - ], - - // Groups: a prefix / filters / requires / name-prefix / domain stated ONCE for - // every route inside. Expanded at boot into flat routes; may nest. - "groups": [ - { "prefix": "/admin", "filters": ["auth"], "name": "admin.", - "domain": "app.shop.local", - "routes": [ - { "method": "GET", "path": "/stats", "handler": "Shop\\Http\\AdminController@stats", - "name": "stats", "requires": ["view.rendering"] } - ] } - ] -} -``` - -Wired by the project bootstrap: - -```php -->withRoutes(EntryHelpers::projectRoutes($projectRoot)) -->withRouteGroups(EntryHelpers::projectRouteGroups($projectRoot)) -->withProjectDomains(EntryHelpers::projectDomains($projectRoot)) -->withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot)) -``` - -- Routes: `EntryHelpers::projectRoutes($projectPath)` reads `proj.json` - `routes[]`; the project bootstrap passes them to `Kernel::withRoutes(...)`. - They compile AFTER all plugin routes and OVERRIDE a plugin route with the same - `METHOD path`. They resolve under the synthetic `__project__` scope (no module - graph); the full-class-path controller autowires from the request container. - Keep project controllers thin — orchestrate published plugin contracts. -- Views: project view paths sort to priority `0` (highest). `render('welcome')` - resolves the project copy before any plugin's; `render('plugin::view')` can be - overridden by dropping `{project-views}/plugin/view.php`. - -### Per-route `requires` — project routes opting into plugins - -The `__project__` scope has an EMPTY dependency graph, so a project route loads -NO plugins by default: on-demand modules' `register()` never runs, their published -contracts are unbound, and a `ViewController` (which constructor-injects -`ViewRendererContract`) cannot even be built. To pull a plugin into ONE project -route without making it essential, declare a route-level `requires[]`: - -```jsonc -// proj.json -{ "method": "GET", "path": "/dashboard", - "handler": "Shop\\Http\\DashboardController@index", - "requires": ["view.rendering"] } -``` - -- `CompileRouteManifestStage` validates each `requires[]` entry at BOOT against - the set of domains some module `solves()` — an unknown/typo'd domain fails the - build with a descriptive message (never a request-time 500). -- `LoadStage` reads the matched route's `requires[]` and seeds those domains - (plus their transitive `requires`) into THAT request's graph only, via - `DependencyGraphCalculator::resolve($service, $additional)`. Routes without - `requires[]` stay lean. -- Scope isolation is unchanged: the required plugin's PUBLIC contract resolves in - the project controller, but its `bindInternal` bindings still throw - `ScopeViolationException` cross-scope. - -| Need | Mechanism | -| --- | --- | -| Some project routes need a plugin | route-level `requires[]` in `proj.json` | -| Every request needs a plugin | `withEssentialModules([...])` | -| The endpoint IS the plugin's domain | declare the route in the plugin's `module.json` | - -Project routes also pass `filters[]` through to the compiler; plugin routes MAY -carry `requires[]` too (they normally get deps via their module's `solves` graph). - -> **Worked examples:** [30_ROUTING_COOKBOOK.md](30_ROUTING_COOKBOOK.md) recipes 6-10 -> cover multi-brand domains, an `api.` subdomain, override + disable, per-route -> `requires` and face restriction — each compiled, with its real output. - -### Domain grouping — one project, several hosts - -`DomainType` only distinguishes admin / api / project / public, so two brands -served by one project are indistinguishable by face. A route **domain group** is -part of the compiled route KEY, so the same path can answer differently per host: - -```jsonc -{ - "domains": ["hkmvote.local", "africavoting.local", "organizer.africavoting.local"], - - "groups": [ - { "domain": "hkmvote.local", "name": "vote.", - "routes": [ { "method": "GET", "path": "/", "handler": "…\\VoteHome@index", "name": "home" } ] }, - { "domain": "africavoting.local", "name": "africa.", - "routes": [ { "method": "GET", "path": "/", "handler": "…\\AfricaHome@index", "name": "home" } ] }, - - { "domain": "organizer.africavoting.local", "prefix": "/dashboard", "filters": ["auth"], - "routes": [ { "method": "GET", "path": "", "handler": "…\\Organizer@index" } ] }, - - { "domain": "*.africavoting.local", // every tenant subdomain - "routes": [ { "method": "GET", "path": "/", "handler": "…\\TenantHome@index" } ] } - ], - - "routes": [ // UNGROUPED = global, all four hosts - { "method": "GET", "path": "/health", "handler": "…\\HealthController@show" } - ] -} -``` - -Compiled keys: `GET /health`, `GET@hkmvote.local /`, `GET@africavoting.local /`, -`GET@organizer.africavoting.local /dashboard`, `GET@*.africavoting.local /`. - -- **Ungrouped routes are GLOBAL** — every domain reaches them. -- A bare **`"subdomain": "api"`** answers on that label of EVERY domain - (`api.example.com` and `api.example2.com`), and is never validated against - `domains` because it belongs to no single host. -- A declared **host** MUST appear in `proj.json` `"domains"` or the boot fails — - a route grouped under a host the project does not serve could never be reached. - A wildcard passes when its parent is registered, or when any registered host - falls under it, which makes it the right tool for tenant hosts that live in the - database rather than in `proj.json`. -- **Route names stay one flat namespace.** Two domains cannot both claim `home`; - use a group `name` prefix (`vote.` / `africa.`). Per-domain names would force - `UrlGenerator` to know which host it is generating for, and it holds no request - state so CLI and workers can build links. -- The host comes from `DomainContext->host` — already VALIDATED against - `projects.json` — via the `route_host` request attribute the entry points set. - Never from the raw `Host` header, which the client controls. - -⚠ **Tenancy:** the domain is read at the ENTRY POINT, before routing. -`TenantContextStage` runs at `after.load`, *after* the route is resolved, so a -tenant row cannot select a route table. Use a wildcard group for "all tenant -hosts share these routes"; per-tenant *layout* differences are a view concern. - -### Route policy — DISABLE plugin routes (the third verb) - -A plugin OWNS and declares its routes, but the deploying project is the FINAL -authority: it can veto plugin routes it will not expose — without forking the -plugin. Declared in `proj.json` and wired by the bootstrap via -`Kernel::withRoutePolicy(EntryHelpers::projectRoutePolicy($projectRoot))`: - -```jsonc -// proj.json -"routePolicy": { - "disable": [ - "GET /register", // one plugin route (method + path) - "GET@organizer /dashboard", // one route inside a domain group - "oauth.server" // a module DOMAIN — every route it solves() - ] -} -``` - -- Two spec forms: `"METHOD /path"` (one exact plugin route) or a bare module - domain (all of that module's routes — the whole-plugin off switch). -- `CompileRouteManifestStage` applies the policy to plugin routes AFTER they - compile and BEFORE project routes — so a project can disable a plugin route - and re-declare its OWN on the freed `METHOD path` with no duplicate-route - boot failure. -- A spec matching NOTHING fails the build with a descriptive message (same - anti-typo guard as unknown `requires[]` domains). Never a silent no-op. -- Project routes (`withRoutes`) are the project's own and are unaffected. - -| Route verb | Mechanism | Result | -| --- | --- | --- | -| add | project `routes[]` | new project route | -| override | project route on a plugin's `METHOD path` | project controller wins | -| disable | `routePolicy.disable[]` | plugin route dropped (404) | - -See the project-over-plugin resource-resolution model in [16_PLUGINS.md](16_PLUGINS.md). - ---- - -### Global (essential) modules — proj.json `"essentials"` - -Which plugins register on EVERY request is a per-project deployment decision, -declared in `proj.json` — not a bootstrap code edit: - -```jsonc -// proj.json — module DOMAINS (a plugin's solves value) -"essentials": ["tenancy.routing", "auth.identity", "user.management"] -``` - -Wired by the bootstrap via -`Kernel::withEssentialModules(EntryHelpers::projectEssentials($projectRoot))`. -Semantics: - -- `withEssentialModules()` accepts provider class-strings AND module domains; a - domain must name a module already in `withModules()` and resolves to its - provider at `build()` — an unknown domain FAILS the boot (never a silent - no-op essential). -- Essential domains are seeded into every request's dependency graph, so an - essential's transitive `requires[]` load with it; each module still registers - exactly once per request. -- Keep the list SHORT — every essential (and its requires graph) is - per-request `register()` cost. -- Session-cookie apps declare `auth.identity` + `user.management` so Auth's - `SessionAuthStage` resolves the logged-in user on every page; JWT/PAT-only - APIs need neither (token layers run before any module loads). -- Multi-tenant projects declare `tenancy.routing`; single-tenant projects leave - Tenancy out of `withModules` entirely. - ---- - -## Rules For Future Project Work - -- Keep business logic out of `app/`, `bootstrap/`, and project bootstrap files -- Project routes go in `proj.json` routes[] / groups[] (or `Kernel::withRoutes()` / - `withRouteGroups()`), never in PHP -- Unwanted plugin routes go in `proj.json` routePolicy.disable[] — never fork a plugin to hide an endpoint -- Every host the project answers on goes in `proj.json` domains[]; a route grouped - under an unregistered host FAILS the boot -- Repeated prefix/filters/requires/name across routes belongs in a `groups[]` entry, - not copy-pasted onto each route -- Set `BOOT_CACHE=1` in production and clear `var/cache/manifests` on deploy — - `Kernel::build()` otherwise recompiles every manifest on EVERY PHP-FPM request -- Put only port/adapters/security/module lists in bootstrap wiring -- Add new projects under `projects/{name}/bootstrap/app.php` -- Ensure module classes listed in `withModules()` have valid `module.json` -- Prefer extending `app/bootstrap/base.php` over copy-pasting full kernel wiring diff --git a/docs/guides/16_PLUGINS.md b/docs/guides/16_PLUGINS.md index 82d9bd6..70c01bb 100644 --- a/docs/guides/16_PLUGINS.md +++ b/docs/guides/16_PLUGINS.md @@ -29,7 +29,13 @@ domains do not match their repo name, so use `hkm plugins domains` rather than g |---|---| | `modules/` | First-party framework packages (`bind-it`, `php-io-cli`, etc.) loaded as Composer path repositories. These are git submodules and may be published to Packagist. | | `projects/` | Project-layer wiring only — bootstrap files, domain resolution, `platform.json`, `projects.json`. No business logic lives here. | -| `plugins/` | Local business modules unique to this application. Full GDA structure. Autoloaded via `Plugins\\` PSR-4 prefix. Never git submodules. | +| `plugins/` | Business modules. Full GDA structure, autoloaded via the `Plugins\\` PSR-4 prefix. In a PROJECT this holds the plugins that project installed (each from its own repo) plus any project-authored ones. In the KERNEL repo it is empty and stays empty. | + +The placement rule, stated once: **the framework holds only code that is for the +framework; `modules/` holds what the framework needs to run; `plugins/` holds +what extends projects.** Every business capability is a plugin, never the kernel. +Port *interfaces* live in `src/Kernel/Ports/` because the kernel defines the +contract; port *implementations* are always plugins. --- @@ -92,7 +98,7 @@ A route entry may also carry `filters[]` (auth, throttle, …) and an optional `requires[]` of extra module domains. A plugin route normally gets its deps via its own `solves` graph, so `requires[]` is rarely needed here — it is the primary mechanism for PROJECT routes (whose `__project__` scope has no graph); see -[11_PROJECT.md](11_PROJECT.md) "Per-route `requires`". Either way, every +the [project-layer docs](https://github.com/AlfaCode-Team/hkm-project-layer/blob/main/docs/PROJECT.md) "Per-route `requires`". Either way, every `requires[]` domain is validated at BOOT — an unknown domain fails the build. --- @@ -116,36 +122,23 @@ return $builder --- -## Registered Plugins - -| Plugin | Namespace | Solves | -|---|---|---| -| SiteSEO | `Plugins\SiteSEO\` | `seo.management` | -| I18n | `Plugins\I18n\` | `i18n.translation` | -| Logger | `Plugins\Logger\` | `logging.application` | -| Crypto | `Plugins\Crypto\` | `crypto.services` | -| Database | `Plugins\Database\` | `database.management` | -| Authorization | `Plugins\Authorization\` | `authorization.policy` | -| Audit | `Plugins\Audit\` | `audit.trail` | -| Settings | `Plugins\Settings\` | `tenant.settings` | -| SocialAuth | `Plugins\SocialAuth\` | `auth.social` | -| Commands | `Plugins\Commands\` | `system.commands` | -| Edge | `Plugins\Edge\` | `edge.routing` | -| DevTools | `Plugins\DevTools\` | `dev.tooling` | - -Infrastructure plugins (port adapters / pipeline stages, no routes) — see -[20_FIRST_PARTY_PLUGINS.md](20_FIRST_PARTY_PLUGINS.md) for the full list and the -module-activation notes (on-demand vs essential): - -| Plugin | Solves | Provides | Activation | -|---|---|---|---| -| Storage | `storage.local` | `StoragePort` (local + S3) | on-demand | -| HttpClient | `http.client` | `HttpClientPort` (cURL) | on-demand | -| Session | `session.management` | `SessionPort` (file/array/cookie drivers) | essential | -| Cookie | `http.cookies` | `CookieJar` + flush stage | essential | -| RedisCache | `cache.redis` | `CachePort` + `QueuePort` | essential | -| SecurityFilters | `http.security_filters` | global hooks: CORS, SecureHeaders. Route-filter aliases: `auth`, `throttle`, `hmac`, `shield` | hooked + filters | -| Tenancy | `tenancy.routing` | `TenantRegistryContract` + `TenantConnectionResolverContract` + `MembershipServiceContract` + `InvitationServiceContract` (database-per-tenant routing + selection/invitation flows; STRICT: every request must resolve a tenant or 404 — no unscoped passthrough; refresh tokens in `Plugins\Auth`; `requires: ["database.management"]` — route-level `requires[]` carry auth/user/audit for its own endpoints) | essential (declare `"essentials": ["tenancy.routing"]` in proj.json) | +## Which Plugins Exist + +`hkm plugins domains` lists every installed plugin with the `solves` domain +it claims — live and authoritative. This repository keeps no static catalogue; +one would go stale, and it already had. + +**A plugin documents itself, in its own repository.** Each one ships a +`README.md` (what it is, how to install it) and a `CLAUDE.md` (its contract, +its `config[]`, and the rules specific to it); some also ship a `docs/` deep +dive. `module.json` is the authoritative source for `requires[]`, `exposes[]`, +`emits[]` and `config[]` — read it there rather than from any summary. + +``` +✗ Documenting a plugin's behaviour, API, env vars or wiring in this repository — + the copy in the kernel is the one that goes stale +✗ Inferring a plugin's requires[] from a table anywhere — open its module.json +``` --- @@ -199,9 +192,22 @@ The resource-resolution model (project-over-plugin, deterministic at boot) is de ## Adding a New Plugin (Checklist) -1. `mkdir -p plugins/{Name}/{API/Contracts,API/Dto,API/IntegrationEvents,Application/Services,Domain/Entities,Domain/ValueObjects,Domain/Events,Infrastructure/Http,Infrastructure/Persistence}` -2. Write `plugins/{Name}/module.json` — set `"type": "module"`, `"solves"`, routes with `Plugins\\{Name}\\...` handlers -3. Implement all layers under `namespace Plugins\{Name}\...` -4. Write `plugins/{Name}/Provider.php` — `namespace Plugins\{Name};` implements `ModuleContract` -5. Add `Plugins\{Name}\Provider::class` to the relevant `projects/*/bootstrap/app.php` -6. Run `composer dump-autoload` if the new namespace isn't picked up automatically +1. `hkm plugins create {name}` scaffolds `plugins/{Name}/` from `templates/plugin/`. + By hand: `mkdir -p plugins/{Name}/{API/Contracts,API/Dto,API/IntegrationEvents,Application/Services,Domain/Entities,Domain/ValueObjects,Domain/Events,Infrastructure/Http,Infrastructure/Persistence}` +2. Write `plugins/{Name}/module.json` — `"type": "module"`, a `"solves"` domain no + other module claims, routes with `Plugins\\{Name}\\...` handlers, and **every + env var the plugin reads** in `config[]` (with a `default` wherever one exists — + that is the value `hkm plugins enable` seeds into the project `.env`). +3. Implement all layers under `namespace Plugins\{Name}\...`, obeying the five + access rules. +4. Write `plugins/{Name}/Provider.php` — `namespace Plugins\{Name};` implements + `ModuleContract`; `solves()`/`requires()`/`exposes()` must mirror `module.json`. +5. Add `Plugins\{Name}\Provider::class` to the relevant + `projects/*/bootstrap/app.php` `withModules([...])`. +6. Run `composer dump-autoload` if the new namespace isn't picked up automatically. +7. Test it with the **Ground** plugin (`PluginGround::for(Provider::class)`) — a + real kernel boot in a temp workspace, not a hand-rolled bootstrap. Gate CI on + `hkm plugin:check`. +8. If it is going to its own repository, give it a `README.md` (install + + capability) and a `CLAUDE.md` (its contract, `config[]` and plugin-specific + rules). Those two files are where the plugin is documented — not in the kernel. diff --git a/docs/guides/19_DATABASE.md b/docs/guides/19_DATABASE.md deleted file mode 100644 index b67d1f1..0000000 --- a/docs/guides/19_DATABASE.md +++ /dev/null @@ -1,355 +0,0 @@ -# 19 — DATABASE MODULE (Multi-Driver Persistence) - -> Enterprise multi-driver implementation of the kernel `DatabasePort`. -> Lives in `plugins/Database/` under the `Plugins\Database\` namespace. -> Solves the `database.management` domain. - ---- - -## WHAT THIS MODULE IS - -The Database module is the **single concrete implementation** of the kernel -`DatabasePort` interface. The kernel defines the port; this module provides a -production-grade adapter that speaks to four database engines through PDO: - -| Engine | Driver key | DSN prefix | -|---|---|---| -| MySQL / MariaDB | `mysql` | `mysql:` | -| PostgreSQL | `pgsql` | `pgsql:` | -| SQLite (file or `:memory:`) | `sqlite` | `sqlite:` | -| SQL Server | `sqlsrv` | `sqlsrv:` | - -Repositories depend on `DatabasePort` only. They never import a driver class or -the adapter — driver selection is an infrastructure concern resolved at boot from -`DB_*` environment variables. - -``` -Repository ──> DatabasePort (kernel interface) - ▲ - │ bound by Plugins\Database\Provider - │ - MultiDriverDatabaseAdapter ──> PDO ──> {MySQL|PostgreSQL|SQLite|SQL Server} -``` - ---- - -## FOLDER STRUCTURE - -``` -plugins/Database/ -├── module.json ← solves database.management, declares DB_* config -├── Provider.php ← wiring only: factory → adapter → DatabasePort -├── API/ -│ └── Contracts/ -│ ├── DatabaseConfigurationContract.php ← driver(), dsn(), pdoOptions(), initStatements() -│ └── DatabaseConnectionManagerContract.php ← named multi-connection registry -├── Infrastructure/ -│ ├── Drivers/ -│ │ ├── DatabaseConfigurationFactory.php ← alias resolution + per-driver defaults -│ │ ├── MySQLConfiguration.php -│ │ ├── PostgreSQLConfiguration.php -│ │ ├── SQLiteConfiguration.php -│ │ └── SqlServerConfiguration.php -│ ├── Persistence/ -│ │ ├── MultiDriverDatabaseAdapter.php ← DatabasePort implementation (direct) -│ │ ├── PooledDatabaseAdapter.php ← DatabasePort implementation (pool-backed, request-scoped) -│ │ ├── ConnectionManager.php ← DatabaseConnectionManagerContract implementation -│ │ └── SavepointGrammar.php ← driver-correct nested-transaction SQL -│ └── Pool/ -│ ├── ConnectionPool.php ← per-worker pool: warmup, validate, evict, stats -│ ├── PoolConfiguration.php ← min/max/timeouts/validate (DB_POOL_*) -│ └── PooledConnection.php ← slot wrapper (lifetime + idle bookkeeping) -└── Exceptions/ - └── ConnectionException.php ← the only exception that escapes the module -``` - ---- - -## THE FIVE ENTERPRISE BEHAVIOURS - -### 1. Lazy connection -The adapter does **not** open a socket in its constructor. PDO is created on the -first query (or explicit `pdo()` / `ping()` call). Booting a module that never -touches the database costs nothing — consistent with GDA "load only what is needed". - -```php -$db = new MultiDriverDatabaseAdapter($config); -$db->isConnected(); // false — no socket yet -$db->query('SELECT 1'); -$db->isConnected(); // true -``` - -### 2. Nested transactions via savepoints -`beginTransaction()` / `commit()` / `rollback()` **nest**. Only the outermost -level drives the real transaction; inner levels use `SAVEPOINT` so a partial -rollback does not abandon the whole unit of work. `SavepointGrammar` emits the -correct dialect (`SAVEPOINT` / `RELEASE` / `ROLLBACK TO` for standard SQL; -`SAVE TRANSACTION` / `ROLLBACK TRANSACTION` for SQL Server). - -```php -$db->transaction(function (MultiDriverDatabaseAdapter $db) { - $db->execute('INSERT ...'); // outer - $db->transaction(fn ($db) => // inner — savepoint - $db->execute('INSERT ...')); -}); // single real COMMIT -``` - -`transaction(callable)` commits on success and rolls back on **any** throwable, -re-throwing the original exception. This is the preferred entry point for service -code that already wraps work in `TransactionManager`. - -### 3. Auto-reconnect -Long-running Swoole workers keep connections for hours. When a statement fails -with a "server has gone away" class error **and no transaction is active**, the -adapter transparently reconnects and retries the statement once. Inside a -transaction it does not retry (the transaction is already invalid) — it surfaces -the error so the caller rolls back. - -### 4. Post-connect init statements -Each driver returns `initStatements()` run immediately after connecting: - -| Driver | Statements | Why | -|---|---|---| -| SQLite | `PRAGMA foreign_keys = ON`, `busy_timeout = 5000`, `journal_mode = WAL`* | FK enforcement is **off by default** in SQLite | -| MySQL | `SET SESSION sql_mode = 'STRICT_ALL_TABLES,…'` | fail on truncation/coercion instead of silent corruption | -| SQL Server | `SET XACT_ABORT ON` | whole-transaction rollback on any runtime error | -| PostgreSQL | — | strict + FK-enforcing by default | - -\* WAL is skipped for `:memory:`. - -### 5. Query observability -Inject an optional PSR-3 `LoggerInterface`. Every statement is timed: -- `logQueries = true` → each query logged at **debug**. -- Any query slower than `slowQueryThresholdMs` (default 200ms) → logged at - **warning**, regardless of the debug flag. - -Set `DB_ENABLE_QUERY_LOG=true` to turn on debug logging through the Provider. - ---- - -## CONFIGURATION (ENV-DRIVEN) - -`DatabaseConfigurationFactory::fromEnvironment()` reads: - -| Variable | Applies to | Default | -|---|---|---| -| `DB_DRIVER` | all (aliases: `mariadb`, `postgres`, `mssql`, `sqlserver`, …) | `sqlite` | -| `DB_HOST` | mysql, pgsql, sqlsrv | driver default | -| `DB_PORT` | mysql, pgsql, sqlsrv | 3306 / 5432 / 1433 | -| `DB_DATABASE` | all (SQLite: file path or `:memory:`) | `:memory:` | -| `DB_USERNAME` / `DB_PASSWORD` | mysql, pgsql, sqlsrv | driver default | -| `DB_CHARSET` | mysql | `utf8mb4` | -| `DB_SSL_MODE` | pgsql (`disable`…`verify-full`) | `prefer` | -| `DB_SSL_VERIFY` / `DB_SSL_CA` | mysql | off | -| `DB_UNIX_SOCKET` | mysql, pgsql | — | -| `DB_ENCRYPT` / `DB_TRUST_SERVER_CERT` | sqlsrv | off | -| `DB_ENABLE_QUERY_LOG` | observability | off | - -Every variable is declared in `module.json` `config[]` — an undeclared variable -read by the module fails boot (GDA rule). - ---- - -## WIRING - -`Provider::register()` performs wiring only — no business logic: - -```php -$container->singleton(DatabaseConfigurationContract::class, fn () => - (new DatabaseConfigurationFactory())->fromEnvironment()); - -$container->bind(DatabasePort::class, fn ($c) => - new MultiDriverDatabaseAdapter( - config: $c->make(DatabaseConfigurationContract::class), - logger: /* optional PSR-3 */, - logQueries: env('DB_ENABLE_QUERY_LOG') === 'true', // env() — never getenv() for .env values - )); - -$container->singleton(DatabaseConnectionManagerContract::class, /* registry */); -``` - -The module is registered in `app/bootstrap/base.php`: - -```php -->withModules([ - Plugins\Database\Provider::class, - Plugins\Commands\Provider::class, -]); -``` - ---- - -## CONNECTION POOLING (OPT-IN, PER WORKER) - -Under OpenSwoole the kernel boots **once per worker** and handles many requests -on that long-lived process. Reconnecting to the database on every request wastes -the TCP/TLS handshake. The pool keeps a bounded set of warm connections and lends -one per request. - -### Topology - -``` -Worker process (app-lifetime) -└── ConnectionPool ← ONE per worker, bound via withPorts (CoreContainer) - ├── idle: [conn, conn, …] ← warm, ready to lend - └── borrowed:{conn, …} ← currently checked out - -Request (request-scoped) -└── PooledDatabaseAdapter (DatabasePort) - └── pins ONE borrowed connection for the whole request, - returns it to the pool on teardown -``` - -`PooledDatabaseAdapter` pins a single connection per request so `lastInsertId()` -and multi-statement transactions stay correct, then `release()`s it on teardown -(`__destruct` is the safety net). Because each request gets its own adapter and -(by default) requests run sequentially per worker, no per-coroutine keying is -needed; when `SWOOLE_COROUTINE=true`, `acquire()` yields the scheduler while -waiting for a free slot. - -### Enabling it - -Set `DB_POOL_ENABLED=true`. The bootstrap (`app/bootstrap/base.php`) builds one -`ConnectionPool` per worker and registers it app-lifetime via `withPorts`; the -module's `Provider` then binds `DatabasePort` to a request-scoped -`PooledDatabaseAdapter`. If no app-lifetime pool is present the Provider falls -back to a container-singleton pool, so the pooled path also works in tests/CLI. - -### Tuning (`DB_POOL_*`) - -| Variable | Default | Meaning | -|---|---|---| -| `DB_POOL_ENABLED` | `false` | Master switch for the pooled DatabasePort | -| `DB_POOL_MIN` | `0` | Connections opened at warm-up and kept hot | -| `DB_POOL_MAX` (alias `DB_POOL_SIZE`) | `10` | Hard ceiling on connections per worker | -| `DB_POOL_ACQUIRE_TIMEOUT_MS` | `3000` | Wait before `poolExhausted` when saturated | -| `DB_POOL_IDLE_TIMEOUT` | `60` | Evict a connection idle longer than this (s) | -| `DB_POOL_MAX_LIFETIME` | `3600` | Recycle a connection older than this (s) | -| `DB_POOL_VALIDATE` | `true` | `ping()` a reused connection before lending | - -A connection that is stale (past idle/lifetime) or fails validation is closed -deterministically (`MultiDriverDatabaseAdapter::close()`) and replaced. A -connection returned mid-transaction is rolled back before re-entering the pool. - -### Observability - -`ConnectionPool::stats()` returns `idle`, `active`, `total`, `max`, `min`, -`waiters`, `closed` — wire it into a health endpoint to watch saturation. - -Sizing rule of thumb: `DB_POOL_MAX × worker_count` must stay under the database -server's `max_connections`. - ---- - -## MULTI-DATABASE (READ REPLICAS / WAREHOUSE) - -`ConnectionManager` implements `DatabaseConnectionManagerContract` for setups -needing more than one connection. Connections are built lazily and cached: - -```php -$manager->register('primary', $primaryConfig); -$manager->register('replica', $replicaConfig); - -$manager->connection('replica')->query('SELECT ...'); // reads -$manager->default()->execute('INSERT ...'); // writes -$manager->close('replica'); // drop one -``` - ---- - -## ERROR HANDLING - -Every `\PDOException` is translated to `Plugins\Database\Exceptions\ConnectionException` -— no vendor exception escapes the module (GDA gateway/repository rule). It carries -structured context for the kernel `ErrorPipeline`: - -```php -try { - $db->query($sql); -} catch (ConnectionException $e) { - $e->driver; // 'mysql' | 'pgsql' | 'sqlite' | 'sqlsrv' - $e->operation; // 'connect' | 'query' | 'execute' | 'transaction.commit' | … - $e->getPrevious(); // original \PDOException -} -``` - -Repositories should catch `ConnectionException` and re-throw a `RepositoryException` -(per [05_REPOSITORY.md](05_REPOSITORY.md)). - ---- - -## TESTING - -The module ships a full unit suite under `tests/Unit/Database/` (85 tests). It uses -**SQLite `:memory:`** as a real connection — no mocking of PDO, so transaction and -savepoint behaviour is genuinely exercised: - -```bash -vendor/bin/phpunit tests/Unit/Database -``` - -Test coverage: -- `Drivers/*ConfigurationTest` — DSN, PDO options, init statements, password redaction -- `Drivers/DatabaseConfigurationFactoryTest` — alias resolution, env parsing, unknown driver -- `Persistence/MultiDriverDatabaseAdapterTest` — CRUD, nested tx/savepoints, `transaction()`, error translation, lazy connect -- `Persistence/ConnectionManagerTest` — named connection registry lifecycle -- `Persistence/QueryLoggingTest` — debug + slow-query logging -- `Exceptions/ConnectionExceptionTest` — structured context - -For repository/service tests, prefer the in-memory adapter or a `DatabasePort` -fake (see [10_TESTING.md](10_TESTING.md)). - ---- - -## CROSS-DRIVER PORTABILITY (UNIFORM API) - -PDO's API and the `:named` placeholder scheme are identical across MySQL, -PostgreSQL and SQLite — but the SQL *text* is not. `DatabasePort` absorbs the -constructs that genuinely differ so repositories never branch on the driver: - -| Need | Use | Never hand-write | -|---|---|---| -| Insert-or-update | `$db->upsert($table, $values, $conflictColumns, $updateColumns)` | `ON DUPLICATE KEY UPDATE` / `ON CONFLICT …` | -| Last insert id | `$db->lastInsertId($sequence = null)` — pass the sequence name on PostgreSQL | `lastInsertId()` assuming MySQL semantics | - -`upsert()` compiles to `INSERT … ON DUPLICATE KEY UPDATE col = VALUES(col)` on -MySQL and `INSERT … ON CONFLICT (cols) DO UPDATE SET col = EXCLUDED.col` on -PostgreSQL/SQLite, quoting identifiers per driver. `$conflictColumns` must have a -matching unique/PK constraint. `$updateColumns`: `null` = all non-conflict -columns, `[]` = do nothing on conflict (insert-if-absent), a subset = only those -(e.g. refresh `role`/`updated_at` but preserve the original `joined_at`). It is -atomic — no UPDATE-then-INSERT race. - -Constructs the port does NOT abstract (keep to the portable subset, or branch on -`$db->driver()` in the rare case you must): string concatenation (`CONCAT` vs -`||`), `SUBSTRING`/`substr`, `bytea`/BLOB streams, and vendor functions. Prefer -computing such values in PHP and binding the result. Full guidance: -[22_DATA_ACCESS_ORM_BLUEPRINT.md](22_DATA_ACCESS_ORM_BLUEPRINT.md). - -## RULES — WHAT NOT TO DO - -``` -✗ Hand-writing ON DUPLICATE KEY / ON CONFLICT — use $db->upsert() (driver-portable) -✗ Importing a driver/adapter class in a repository — depend on DatabasePort only -✗ Reading DB_* env vars anywhere but DatabaseConfigurationFactory -✗ Letting a \PDOException escape the module — always ConnectionException -✗ Putting business logic in Provider — wiring only -✗ float for money columns — integer cents (see Domain/ValueObjects rules) -✗ Opening the connection eagerly in a constructor — it is lazy by design -✗ Catching ConnectionException and swallowing it — translate to RepositoryException -✗ Adding a 5th driver without an initStatements() review and a config test -✗ Making PooledDatabaseAdapter app-lifetime — it MUST be request-scoped (per-request pin) -✗ Making ConnectionPool request-scoped — it MUST be app-lifetime (one per worker) -✗ Holding a borrowed connection across requests without release() — starves the pool -✗ Setting DB_POOL_MAX × workers above the server's max_connections -``` - ---- - -## RELATED CONTEXT - -- [05_REPOSITORY.md](05_REPOSITORY.md) — repository layer rules (DatabasePort only) -- [18_MIGRATIONS.md](18_MIGRATIONS.md) — LetMigrate uses the same `DB_*` variables -- [16_PLUGINS.md](16_PLUGINS.md) — plugins folder convention -- [10_TESTING.md](10_TESTING.md) — port fakes and service tests -``` diff --git a/docs/guides/20_FIRST_PARTY_PLUGINS.md b/docs/guides/20_FIRST_PARTY_PLUGINS.md deleted file mode 100644 index f4ff1fa..0000000 --- a/docs/guides/20_FIRST_PARTY_PLUGINS.md +++ /dev/null @@ -1,586 +0,0 @@ -# First-Party Plugins — Ported / Built Capabilities - -These plugins live under `plugins/` (namespace `Plugins\`) and were added to give -the GDA kernel capabilities it intentionally did not ship with. Each follows the -plugin convention in `16_PLUGINS.md`: a `module.json`, a `Provider`, and the GDA -layer layout. Register a plugin by adding `Plugins\{Name}\Provider::class` to a -project bootstrap (most are already in `app/bootstrap/base.php` or -`projects/admin/bootstrap/app.php`). - -| Plugin | solves | Exposes / provides | -|---|---|---| -| `Authorization` | `authorization.policy` | `AuthorizationServiceContract` (Casbin RBAC/ABAC) | -| `Auth` | `auth.identity` | `AuthServiceContract` + JWT/PAT/session SecurityLayers (asymmetric signing, `jti` revocation, `SessionAuthStage`, `/auth/login\|logout\|me`). **Deep dive: [25_AUTH.md](25_AUTH.md)** | -| `OAuth2` | `oauth.server` | Native OAuth 2.1 + OIDC authorization server (auth-code/PKCE, client-credentials, refresh, password, device; JWKS, introspection/revocation, discovery). Access tokens are platform JWTs. **Deep dive: [26_OAUTH2.md](26_OAUTH2.md)** | -| `SocialAuth` | `auth.social` | `SocialAuthServiceContract` (OAuth1/OAuth2) | -| `SecurityFilters` | `http.security_filters` | global hooks (CORS, SecureHeaders) + route-filter aliases (`auth`, `throttle`, `hmac`, `shield`) | -| `Crypto` | `crypto.services` | `EncryptionPort` + `HashingPort` adapters | -| `Validation` | — (library) | `Validator` rules engine | -| `I18n` | `i18n.translation` | `Translator` — file-based `{APP_LANG_PATH}/{locale}/{group}.php`, dotted `group.key`; `:name`/`:Name`/`:NAME` placeholders (longest-first `strtr`); `choice()` pluralization (`singular\|plural` or ranges `{0}`/`[1,19]`/`[20,*]`); never throws (miss → fallback locale → key). `LocaleStage` (after.load p45) negotiates `Accept-Language` vs `APP_LOCALES` + binds global helpers `__()`/`trans()`/`trans_choice()`/`lang_has()` | -| `Support` | — (library) | `Collection`, `Arr`, `Str`, `Resource`, `collect()` | -| `Mail` | `mail.smtp` | `MailPort` SMTP adapter | -| `Pageflow` | `http.pageflow` | `PageflowResponder` + `PageflowChannel` (Inertia v2 SPA bridge: CSRF, validation/precognition, reactive props, auth, offline) | -| `DevTools` | `dev.tooling` | `make:*`, `module:list/info`, `routes:list`, `project:list` | -| `Storage` | `storage.local` | `StoragePort` — local disk + S3 driver (Flysystem), signed URLs | -| `View` | `view.rendering` | `ViewRendererContract` — PHP template engine (layouts, sections, decorators) | -| `HttpClient` | `http.client` | `HttpClientPort` — cURL client, fluent builder, multipart | -| `Session` | `session.management` | `SessionPort` — file/array/cookie handlers, flash, CSRF, lazy persist | -| `Cookie` | `http.cookies` | `CookieJar` — queued cookies, encrypt/decrypt via `EncryptionPort` | -| `RedisCache` | `cache.redis` | `CachePort` + `QueuePort` — ext-redis, in-memory fallback | -| `Tenancy` | `tenancy.routing` | `TenantRegistryContract` + `TenantConnectionResolverContract` + `MembershipServiceContract` + `InvitationServiceContract` + `TenantHostServiceContract` — database-per-tenant routing + selection/invitation/custom-host flows. (Refresh tokens moved to `Plugins\Auth`.) **Deep dive: [23_TENANCY.md](23_TENANCY.md)** | -| `User` | `user.management` | `UserServiceContract` — GLOBAL central identity (CRUD, credential/email verification, transactional outbox, audit_log). **Deep dive: [24_USER.md](24_USER.md)** | - -Activation: `Storage`, `View`, and `HttpClient` are **on-demand** (a consumer -declares `requires: ["storage.local"]` / `["view.rendering"]` / `["http.client"]`). -`Session`, `Cookie`, and -`RedisCache` are **essential** (registered every request via -`withEssentialModules` in `app/bootstrap/base.php`). `SecurityFilters` runs -`CorsStage` + `SecureHeadersStage` as global hooks and registers the `auth` / -`throttle` / `hmac` / `shield` route-filter aliases (opt in per route via -`"filters": [...]`). See `16_PLUGINS.md` and the SecurityFilters section below for the hook-vs-filter -and module-activation models. - ---- - -## Storage (local + S3) - -`StoragePort` adapter. `STORAGE_DRIVER=local` (default) uses atomic, fsync'd file -writes under `STORAGE_ROOT` (short-write detection guards against silent -disk-full corruption) with HMAC-signed `temporaryUrl()`; `STORAGE_DRIVER=s3` uses -`league/flysystem-aws-s3-v3` (AWS S3 / DigitalOcean Spaces / Cloudflare R2 / MinIO) -with native pre-signed URLs. On-demand: a consuming module declares -`{ "requires": ["storage.local"] }`. - -**S3 credentials:** leave `STORAGE_S3_KEY` empty on EC2/ECS/EKS — `fromConfig()` -then omits static credentials so the AWS default provider chain (IAM -instance/task roles, env, SSO) resolves them. Only set the key/secret for -non-AWS providers or local dev. The adapter is bound as a request-scoped -**singleton**, so the `S3Client` is built once per request, not per resolution. - -**Configuration** is env-driven through `config/storage.php`, read via the -`storage_config()` helper (dotted access; a project copy at -`projects//config/storage.php` overrides the plugin default): - -```php -storage_config('driver'); // 'local' | 's3' -storage_config('local.root'); // STORAGE_ROOT -storage_config('s3.bucket'); // STORAGE_S3_BUCKET -``` - -**Streaming** (large blobs, no full in-memory buffer): - -```php -$path = $storage->store($bytes, 'invoice.pdf', 'invoices/2026', 'private'); -$url = $storage->temporaryUrl($path, 600); - -$storage->storeStream($readable, 'export.csv', 'exports'); // stream → storage -$handle = $storage->readStream('exports/export.csv'); // storage → stream (caller closes) -``` - -Env keys: `STORAGE_DRIVER`, `STORAGE_ROOT`, `STORAGE_URL_BASE`, -`STORAGE_URL_SECRET`, `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_KEY`, -`STORAGE_S3_SECRET`, `STORAGE_S3_ENDPOINT`, `STORAGE_S3_PATH_STYLE`. - -## View (PHP templates) - -`ViewRendererContract` — a PHP template engine ported from CodeIgniter 4 and -rebuilt to GDA rules: **no globals** (view paths, extensions, decorators and the -HTML escaper are all constructor-injected; the engine reads no `config()`/`kernel()`), -**request-scoped** (bound per request, so its mutable template data never leaks -across requests under OpenSwoole), and no file-locator dependency (views resolve -against the injected paths). Supports data binding with optional escaping, -layouts (`$options['layout']` or `extend()`/`section()`), section rendering, -includes and output decorators. On-demand: `{ "requires": ["view.rendering"] }`. - -`Plugins\View\Infrastructure\SidebarManager` ships alongside as a navigation-HTML -builder (instance-scoped icon cache — never `static`). - -Config (env; `VIEW_PATHS` unset → defaults to `/resources/views`): -`VIEW_PATHS` (colon/comma-separated dirs), `VIEW_EXTENSIONS` (default `php`), -`VIEW_SAVE_DATA` (persist data across `render()` calls). - -```php -// Controller injects ViewRendererContract (its module requires "view.rendering"): -return Response::html( - $this->view->setVar('name', $user->name) // pass raw… - ->render('welcome', ['layout' => 'layouts/app']) -); -// Escape ONCE: either pre-escape via setVar(..., 'html') AND echo raw in the -// template, OR pass raw and escape in the template — never both (double-escapes). -``` - -## HttpClient (outbound cURL) - -`HttpClientPort` adapter for Gateways. Dependency-free cURL with an immutable -fluent builder, safe retry/backoff, and manual multipart uploads. Vendor/transport -errors are translated to `GatewayException`. On-demand: `{ "requires": ["http.client"] }`. - -The fluent builder is reachable **through the port** — `HttpClientPort::pending(): -PendingRequestContract` — so a Gateway typed against the kernel contract (never the -concrete adapter) can still use `baseUrl()`, `withToken()`, `asForm()`, `attach()`, -etc. Both `pending()` and the returned `PendingRequestContract` live in the kernel -`Ports` namespace. - -```php -$res = $client->pending()->acceptJson()->withToken($t)->post($url, $payload); -if ($res->ok()) { $data = $res->json(); } -$client->pending()->asMultipart()->attach('file', $bytes, 'a.png')->post($url); -``` - -Hardening / behaviour to rely on: - -- **Retries are idempotent-only by default.** `retry(n)` retries transport failures - AND transient responses (5xx / 429), but ONLY for `GET/HEAD/PUT/DELETE/OPTIONS/TRACE` - — a POST/PATCH is never silently re-executed. Widen deliberately (e.g. an - idempotency-key POST) with `->retryMethods([...])` (builder) or the `retry_methods` - request option. Backoff is coroutine-aware (OpenSwoole/Swoole `Coroutine::usleep`, - else `usleep`) so it never blocks the worker. -- **Header injection is rejected** — CR/LF in any header name/value throws; multipart - field/file names are stripped of CR/LF and `"`. -- **JSON bodies use `JSON_THROW_ON_ERROR`** — an un-encodable payload throws a - `GatewayException`, never ships a silent `{}`. -- **OOM guard** — responses are capped at `HTTP_CLIENT_MAX_RESPONSE_BYTES` - (default 32 MiB) via an aborting cURL progress callback. -- TLS verification on by default; gzip/deflate negotiated transparently; `NOSIGNAL` - set for threaded/Swoole SAPIs. - -Config env: `HTTP_CLIENT_TIMEOUT` (30), `HTTP_CLIENT_CONNECT_TIMEOUT` (10), -`HTTP_CLIENT_RETRY` (0), `HTTP_CLIENT_MAX_RESPONSE_BYTES` (33554432). - -Live demo: `HttpClientController` in `psp-shop` (`/http/get`, `/http/fluent`, -`/http/post`, `/http/error`). - -## Session (essential) - -`SessionPort` adapter with native `\SessionHandlerInterface` handlers -(`SESSION_DRIVER=file|array|cookie`), flash data, CSRF `token()`, `regenerate()`/ -`invalidate()` for fixation defence, and **lazy persistence** — a fresh visitor -who never writes the session gets no file and no cookie (stateless API/bot traffic -stays clean). `StartSessionStage` (hooked `after.load`) opens it before modules -and persists + sets the cookie after, only when `shouldPersist()`. - -> Apps must call `$session->regenerate()` after login (fixation defence). The -> kernel's CSRF layer is double-submit-cookie based and independent of `token()`. - -### Drivers - -| `SESSION_DRIVER` | Storage | Notes | -|---|---|---| -| `file` (default) | one file per session under `var/sessions/` | server-side; `SESSION_PATH` overrides the dir | -| `array` | in-memory (per process) | tests / CLI / stateless contexts | -| `cookie` | **in the session cookie itself** | stateless & horizontally-scalable — no server store | - -### Cookie driver — stateless, encrypted/signed sessions - -`CookieSessionHandler` carries the whole serialized attribute bag inside the -session cookie, so nothing is stored server-side (ideal for multi-node deploys). -Defence in depth, all env-driven: - -- **Protection** — encrypted via `EncryptionPort` when `APP_KEY`/Crypto is present - (confidential + authenticated); otherwise **HMAC-SHA256 signed** with - `SESSION_SIGNING_KEY` (falls back to `APP_KEY`) — readable but tamper-evident, - verified with `hash_equals()`. -- **Timeouts** — `SESSION_LIFETIME` (absolute, never extended by re-saving) and - `SESSION_IDLE_TIMEOUT` (sliding), both enforced server-side on read. -- **Fingerprint binding** — `SESSION_COOKIE_FINGERPRINT=off|ua|ip|ua,ip` ties the - session to a hashed client fingerprint. `ua` survives IP changes (safe for - mobile); `ip`/`ua,ip` are stricter anti-theft. -- **Compression** — `SESSION_COOKIE_COMPRESS` deflates data above N bytes to fit - more under the ~4 KB cookie limit; `SESSION_COOKIE_MAX_BYTES` drops an oversized - cookie (and expires any stale one) rather than emit an invalid `Set-Cookie`. -- **Hard guards** — `SESSION_COOKIE_REQUIRE_AUTH` (default on) fails boot unless - signed or encrypted; `SESSION_COOKIE_REQUIRE_ENCRYPTION` fails boot unless - *encrypted* (blocks the signed-but-readable mode for confidential data). -- **Cookie attributes** — `SESSION_SECURE=auto|true|false`, plus - `SESSION_COOKIE_PATH` / `SESSION_COOKIE_DOMAIN`. -- Binary-safe regardless of `SESSION_SERIALIZATION` (`json` default | `php`). - -> Keep cookie sessions small (ids/flags/CSRF) — they ride on every request and are -> capped at ~4 KB. Use `file` (or a Redis driver) for large session state. - -## Cookie (essential) - -`CookieJar` queues outgoing cookies flushed by `QueuedCookiesStage`; values are -encrypted via `EncryptionPort` (except an exempt list). Read incoming cookies with -`$jar->read($request, $name)` (auto-decrypts; exempt cookies returned raw). -Encryption is only meaningful with `APP_KEY` set — the kernel hard-fails at boot -outside `local`/`testing` when it is missing. - -**Config — `plugins/Cookie/config/cookie.php` (env-driven; project override wins).** -A project may copy it to `projects//config/cookie.php`; `cookie_config()` -resolves the project file first (via `Paths::config()`), else the plugin default. -Every value reads from `.env`: - -| Env | Key | Default | -|---|---|---| -| `COOKIE_LIFETIME` (minutes) | `lifetime` | `120` | -| `COOKIE_PATH` | `path` | `/` | -| `COOKIE_DOMAIN` | `domain` | `null` (bind to issuing host) | -| `COOKIE_SECURE` | `secure` | `true` (set `false` for local http://) | -| `COOKIE_HTTP_ONLY` | `http_only` | `true` | -| `COOKIE_SAME_SITE` | `same_site` | `Lax` | -| `COOKIE_ENCRYPT_EXEMPT` (comma-separated) | `encrypt_exempt` | `[]` | - -`CookieJar::queue()` attributes are nullable — omitted ones fall back to these -defaults, so callers usually pass only name + value. - -**Encryption exemptions (`encrypt_exempt`).** Names listed here are written AND -read as plaintext — `CookieJar` skips both `encryptString()` on flush and -`decryptString()` on `read()` for them. The final list is a base array declared -in `config/cookie.php` MERGED with the comma-separated `COOKIE_ENCRYPT_EXEMPT` -env var (de-duplicated), so deployments can add names without editing code. -Exempt a cookie when its raw value must stay stable and readable as-is: - -- a JS-readable flag (theme, locale) the front-end reads directly; or -- an opaque session/binding cookie a **pre-load security layer** reads raw — e.g. - `CsrfTokenLayer`'s `bindCookie`. Encryption rotates the ciphertext on every - response (random IV), which would break that binding; exempting it keeps the - value byte-stable across requests. See [CSRF guide](21_CSRF.md). - -**Helpers (`plugins/Cookie/Support/helpers.php`, autoloaded):** - -```php -cookie_config(); // full config array (cached per process) -cookie_config('same_site'); // single key -$jar->queue(...cookie('cart', $id, minutes: 30)); // spread into queue() -Response::json($d)->withCookie(...cookie('seen', '1')); // or into withCookie() -``` - -`cookie()` returns a spread-ready attribute array (keys match both -`CookieJar::queue()` and `Response::withCookie()`); `maxAge` is in seconds. - -> `.env` gotcha: an empty value followed by an inline comment (`COOKIE_DOMAIN= # note`) -> resolves to empty — `LoadEnvironment` treats a comment-only value as `''`. Put -> comments on their OWN line to avoid surprises with non-empty values. - -## RedisCache (essential) - -`CachePort` + `QueuePort` on ext-redis (one shared lazy connection). Numbers are -stored raw so `increment()`/`set()`/`get()` interoperate (the rate limiter relies -on this); everything else is serialized. `deletePattern()` uses non-blocking SCAN. -Only binds when `REDIS_HOST` is set (else the in-memory `CachePort` stays). -`REDIS_PERSISTENT=true` enables `pconnect` reuse (FPM only — keep off on Swoole). - ---- - -## Authorization (Casbin) - -Casbin policy engine wrapped for GDA. Policy storage goes through `DatabasePort` -via `DatabasePolicyAdapter` (table `casbin_rule`); the `Enforcer` is an internal -binding and only `AuthorizationServiceContract` is exposed. - -```php -$authz->allows($userId, 'invoice:42', 'edit'); // bool -$authz->assignRole($userId, 'admin', $tenantId); -$authz->grant('admin', 'invoice', 'edit'); -``` - -Model config: `plugins/Authorization/config/rbac_model.conf` (override with -`AUTHZ_MODEL_PATH`). Run the bundled migration to create `casbin_rule`. - -## Auth (JWT + Personal Access Tokens) - -Credential **issuance** is `AuthServiceContract` (`issueJwt`, `createPersonalAccessToken`, -`hashPassword`/`verifyPassword` via `HashingPort`). Credential **verification** is -done by SecurityLayers wired into the kernel `withSecurity([...])` chain: - -- `JwtAuthLayer(secret, algo)` — validates `Authorization: Bearer `. -- `PersonalAccessTokenLayer(databasePort)` — validates DB-backed `` tokens. - -No header → anonymous (public routes still work). Invalid token → `deny(401)`. -PATs are looked up by deterministic `sha256` (passwords use bcrypt via `HashingPort`). - -## SocialAuth (OAuth) - -Ported OAuth providers (GitHub, Google, Facebook, GitLab, Bitbucket, LinkedIn, -Slack, X). A small compat layer (`Socialite/Http`, `Socialite/Support`) lets the -stateful OAuth flow run inside the stateless kernel. OAuth2 drivers work out of -the box; the Twitter OAuth1 driver also needs `league/oauth1-client` + `phpseclib`. - -```php -$social->redirectUrl('github'); // start -$social->userFromCallback('github', $request); // resolve user -``` - -## SecurityFilters (HTTP stages) - -The 0.3 filters rebuilt as `HttpStageContract` stages. CORS + SecureHeaders run as -GLOBAL pipeline hooks (every request); HMAC, auth, Shield and the rate limiter are -exposed as DECLARATIVE route-filter aliases that a route opts into by name. A stage -runs through exactly ONE mechanism — never both (double-registering double-runs it). - -**Global hooks** (registered in `Provider::boot()`, run on every request): - -| Stage | Slot | Config | -|---|---|---| -| `CorsStage` | after.security | `CORS_ALLOWED_ORIGINS/METHODS/HEADERS`, `CORS_ALLOW_CREDENTIALS`, `CORS_MAX_AGE` | -| `SecureHeadersStage` | after.execute | `CONTENT_SECURITY_POLICY`, `HSTS_MAX_AGE` | - -**Route-filter aliases** (registered via `$http->filter(...)`; a route opts in with -`"filters": [...]` in module.json / proj.json): - -| Alias | Stage | Config | -|---|---|---| -| `hmac` | `HmacSignedStage` | `HMAC_PROTECTED_PREFIX`, `REQUEST_SIGNING_SECRET`, `HMAC_MAX_SKEW` | -| `auth` | `RequireAuthStage` | also honours `AUTH_PROTECTED_PATHS` (exact / `prefix/*` / `*` segment) | -| `shield` | `ShieldStage` | `SHIELD_RULES` (`/path=role:admin;/x=perm:y`) | -| `throttle` | `ApiRateLimitStage` | `RATE_LIMIT_PREFIX/MAX/WINDOW` (uses `CachePort`); `"throttle:max,window"` args | - -```jsonc -// require auth + throttle on one route, declaratively -{ "method": "POST", "path": "/api/tasks", "handler": "...@create", - "filters": ["auth", "throttle:60,1"] } -``` - -`RequireAuthStage` enforces when EITHER the route declared the `auth` filter OR the -path is in `AUTH_PROTECTED_PATHS` — the auth layer attaches Identity globally, this -stage decides which routes demand it. See [16_PLUGINS.md](16_PLUGINS.md) for the hook-vs-filter model and `RouteFilterStage` / `FilterRegistry` internals. - -## Crypto (kernel ports) - -Adds two **kernel ports** the framework was missing, with adapters: - -- `EncryptionPort` → `AesEncrypter` — authenticated AES-256-GCM with key rotation. -- `HashingPort` → `PasswordHasher` — bcrypt/argon2 over `password_*`. - -Wired in `app/bootstrap/base.php` from `APP_KEY` / `APP_KEY_PREVIOUS` / -`HASH_BCRYPT_COST`. **Set a real 32-byte `APP_KEY` in production.** - -## Validation - -Dependency-free rules engine that throws the kernel `ValidationException` -(field → messages, the standard 422 shape). Optional `Translator` for localized -messages. - -```php -Validator::make($request->all(), [ - 'email' => 'required|email', - 'age' => 'required|integer|min:18', - 'password' => 'required|min:8|confirmed', -])->validate(); // returns validated data or throws -``` - -Rules: `required, nullable, string, integer, numeric, boolean, array, email, url, -min, max, between, in, regex, same, different, confirmed`. - -## I18n - -File-based `Translator`: `lang/{locale}/{group}.php`, dotted keys, `:placeholder` -substitution, locale→fallback→key resolution, path-traversal guarded. -Config: `APP_LOCALE`, `APP_FALLBACK_LOCALE`, `APP_LANG_PATH`. - -## Support - -`Collection` (fluent, immutable-friendly), `Arr`, `Str`, and `Resource` / -`ResourceCollection` (API transformers). `collect()` helper autoloaded. - -```php -collect($rows)->map(...)->where('active', true)->pluck('id')->all(); -UserResource::collection($users)->toArray(); -``` - -## Mail (SMTP) - -`SmtpMailer` implements `MailPort` over a dependency-free `SmtpTransport` -(STARTTLS/SSL, AUTH LOGIN). Bound only when `SMTP_HOST` is set, so unconfigured -projects are unaffected. Renders PHP-template views or inline HTML. - -## Pageflow (SPA bridge — `http.pageflow`) - -A fork of **Inertia.js v2**, rebranded and wired into the kernel, with -platform-native capabilities Inertia lacks. Server side + the React client both -live in `plugins/Pageflow/` (PHP) and `plugins/Pageflow/ui/` (client). Full usage -guide: `plugins/Pageflow/ui/PAGEFLOW_GUIDE.pdf`. - -### Core protocol - -`PageflowResponder::render($request, $component, $surface, $props = [], -$viteEntry = null, $loadPage = true, $cacheable = false)` returns a JSON page -object for `X-Pageflow` XHR navigations -or an HTML shell on first load (the client boots from the root element's -**`data-page`** attribute — `PageflowPage::mount($appId)` — NOT -`window.initialPage`). Honours partial reloads (`X-Pageflow-Partial-*`). -`PageflowVersionStage` returns `409 + X-Pageflow-Location` on stale assets. -Shared props via `pageflow_share('key', fn($request) => …)`. - -### CSRF - -The responder renders `` into the HTML head (minted from -`APP_KEY` + the session-cookie binding via `CsrfTokenLayer::make`). The client -reads it and sends `X-CSRF-Token` on mutations — **same-origin only** (never -leaked cross-origin). `GET /pageflow/csrf` (throttled) refreshes an expired token -for long-lived tabs; the client's axios interceptor auto-refreshes on a CSRF 403. -The token is intentionally NOT shared as a prop (kept out of JSON / SW cache). - -### Native validation & precognition - -`PageflowValidationStage` turns a kernel `ValidationException` into either a -`422 {errors}` (precognition) or a session-flashed **303 redirect-back** (normal -submit) — controllers just throw via their DTOs; the `errors` shared prop -surfaces them and `useForm` shows them (`preserveState` keeps the form). The -303 `Location` is reduced to a same-origin path (no open redirect). Precognition -(`Precognition: true`) runs validation only; a controller short-circuits with -`pageflow_precognition($request)` → `PageflowResponder::precognitionSuccess()`. -`PageflowPrecognitionStage` flags the request (`precognition` attribute) so a -repo/service can refuse writes. - -### Reactive props (secure server push) - -`PageflowChannel` (CachePort-backed): a Service calls -`$channel->touch("t:{$tenantId}:dashboard", ['orders'])` after commit; the -tenant-scoped `GET /pageflow/stream` SSE endpoint (auth-gated) pushes **stale key -names only — never data**. The client (`useReactiveProps`) reacts with a normal -authorized partial reload. Reconnect-safe via SSE `id:`/`Last-Event-ID`; bounded -lifetime (`PAGEFLOW_STREAM_MAX_SECONDS`). Requires OpenSwoole for real push. - -### Auth projection - -`pageflow_auth` shared prop (via `PageflowAuth`, override with -`pageflow_auth_projection()`) exposes userId/tenant/roles/permissions — -**never tokens**. Client `useAuth()`/`` gate UI (UX only; server stays the -authority). `useFlushOnIdentityChange()` purges prefetch + SW cache on -login/logout/tenant-switch. - -### Offline (opt-in) - -`registerPageflowSW()` + `pageflow-sw.js`: static assets cache-first; page objects -cached **only** when the server opts in (`render(..., cacheable: true)` → -`X-Pageflow-Cache: 1`, or `Cache-Control: public`) — authenticated pages are never -cached by default. `no-store`/`private` always win. - -### Client API (`@pageflow/react`) - -``, `useForm` (+ `resetOnSuccess`/`resetOnError`), `usePage`, ``, -`
`, `usePrecognition`, `useReactiveProps`, `useAuth`, -``, `useDirtyGuard`, `usePoll`, `usePrefetch`, `useRemember`, `Deferred`, -`WhenVisible`, `installCsrfAutoRefresh`, `registerPageflowSW`. Batched deferred -props (N groups → 1 request). CLI `pageflow:types` generates end-to-end `.d.ts`. - -### Endpoints & env - -Routes: `GET /pageflow/csrf` (throttle), `GET /pageflow/stream` (auth + -throttle). Env: `PAGEFLOW_VERSION`, `PAGEFLOW_ROOT_VIEW`, `PAGEFLOW_APP_ID`, -`PAGEFLOW_CSRF_COOKIE`, `PAGEFLOW_CSRF_LIFETIME`, `PAGEFLOW_STREAM_INTERVAL`, -`PAGEFLOW_STREAM_MAX_SECONDS`, `PAGEFLOW_PRECOGNITION_ROLLBACK`. - -## SiteSEO (`seo.management`, on-demand) - -Full SEO toolkit + Project-layer support. `requires: ["http.client"]`. Published -`SeoServiceContract`: `openGraph()`, `schema()`, `sitemap()`, `robots()`, -`pingSitemap()`, `indexNow(host,key,keyLocation,urls,endpoints,dryRun)` -(auto-batches 10k, lazy iterable), `indexNowChunks()`. All outbound HTTP goes -through `Infrastructure/Gateways/SearchEngineGateway` (`HttpClientPort`) — never -raw cURL. The toolkit value classes (`OpenGraph`, `Schema`, `Sitemap*`, -`RobotsTxtEditor`) autoload directly, so building sitemaps / OG / JSON-LD needs -NO module load; only ping + IndexNow do (they hit the network). - -Project-layer helpers (`Project\Support\Seo\`, reusable & DI-free): - -- `RouteCatalog` — public static GET pages from the route manifest (drops - `{param}`, auth-gated, `/api`, SEO endpoints). -- `SitemapGenerator` — small/route-derived `` (≤30k); `toXml()`/`save()`. -- `SitemapStreamWriter` — **enterprise**: streams an `iterable` to split child - files + index at **O(1) memory** (no DOM), 50k split, optional gzip. For - millions of URLs (verified flat memory to 1M+). -- `SitemapUrlProvider` + `SitemapSource` — expand dynamic routes (`/blog/{slug}`) - from the DB with a keyset-cursor generator; `uncoveredDynamicRoutes()` guards - silent omissions. -- `RichGraph` — Schema.org JSON-LD `@graph` for Google rich results (org → - website[SearchAction] → webPage → breadcrumb → content node, linked by `@id`). - Content nodes: article/newsArticle/blogPosting, product (offer+rating+review), - book, course (syllabus), realEstate (lease), pageantEdition/awardEdition/ - contestant (Event+Person), faq. -- `SeoHead` — full ``: title, description, **canonical**, **robots**, - **hreflang**/x-default, plus attached OG + JSON-LD. -- `IndexNowKey` — key/keyLocation value object. - -Controller traits (`Project\Http\Controllers\Concerns\`): `InteractsWithSeo` -(siteBaseUrl, sitemap, openGraph, ogImage, richGraph, robots) and -`InteractsWithGraphSeo` (adds `graph()` + `seoHead()`). - -Background indexing: job `seo.indexnow` (`IndexNowJob`, queue `indexing`, -declared in `module.json` `jobs[]`, bound in `Provider::register()`) submits one -≤10k batch; dispatch by chunking a URL stream and `QueuePort::push()` per batch -(`FileQueue` in `Project\Infrastructure\` is the no-Redis fallback). Index-on- -publish: emit `UrlPublishedIntegrationEvent` after commit → SEO module subscribes -`EnqueueIndexNowListener` (`Provider::boot()`) which enqueues. The EventBus -resolves listeners from the CoreContainer (`has()` bound-only), so the **project -binds the listener with its `QueuePort`** in `bootstrap/app.php`. Env: -`INDEXNOW_KEY` (listener no-ops without it), `INDEXNOW_LIVE`. - -`NOTE` the toolkit had two real bugs fixed during integration: `Schema` now emits -a proper multi-node `@graph` (was serializing only `things[0]`), and the Twitter -card no longer leaks `og:image:*` keys when a structured image is attached. - -## Tenancy (multi-tenant control plane) - -`solves: tenancy.routing`, `requires: ["database.management"]`, **essential**. -Database-per-tenant isolation layered on `plugins/Database`'s `ConnectionManager`. - -Two planes: a **central (control) DB** holds `users`, `tenants`, `user_tenants` -(+ optional invitations/refresh-tokens/audit); each **tenant has its own DB** -containing only business domain (no auth, no `tenant_id` column — the database is -the boundary). User references inside a tenant DB store the central -`users.user_id` ULID as an opaque value (no cross-DB FK). - -Flow: the Auth layer mints a tenant-scoped `Identity` (JWT `tnt` claim → -`Identity.tenantId`) after the user selects a tenant, re-checking `user_tenants` -each request so a revoked membership drops access before the token expires. -`TenantContextStage` (hooked at `after.load`) reads `Identity.tenantId`, asks -`TenantConnectionResolver` for that tenant's `DatabasePort`, and **rebinds -`DatabasePort` in the request container** — every repository then transparently -talks to the tenant DB. - -- **`TenantRegistry`** — cached reads of central `tenants` (DatabasePort-only, - reads the `ConnectionManager` default = central connection). -- **`TenantConnectionResolver`** — `tenant_id → DatabasePort`; registers a named - `tenant:` connection (password decrypted via `EncryptionPort` at connect - time only). **Fail-closed**: unknown/suspended/deleted/unreachable → throw, - never falls back to another tenant or central. Per-tenant **circuit breaker** - (`TENANCY_BREAKER_THRESHOLD`/`TENANCY_BREAKER_COOLDOWN`) isolates one dead - tenant DB from the fleet. -- **Swoole-safe**: tenant `DatabasePort` is bound into the per-request - `ModuleContainer` (discarded on `reset()`); tenant id rides on the immutable - `Request`/`Identity`, never a static or `CoreContainer`. For cross-request - pooling, bind `ConnectionManager` + resolver into the `CoreContainer` in - bootstrap (see the plugin README) and LRU-evict idle tenant connections. -- **CLI**: `tenants:create` (registry row → CREATE DATABASE → template migrate → - activate, with compensating `provisioning` status) and `tenants:migrate` - (resumable, failure-isolated fleet migrator; each tenant DB keeps its own - `let_migrations` table; central `tenants.schema_version` mirrors drift). -- **Tenant template** migrations live in `plugins/Tenancy/database/tenant-template/` - (override via `TENANCY_TEMPLATE_PATH`). Use expand→migrate→contract for - destructive changes and canary waves across the fleet. -- **Tenant-selection flow** (`MembershipServiceContract`, requires `auth.identity`): - `GET /api/me/tenants` lists active seats; `POST /api/tenants/{tenantId}/select` - re-verifies the membership against central `user_tenants` (never trusts a - client-supplied id), mints a tenant-scoped token via the Auth module (`tnt` - claim), and audits `tenant.switch`. `TENANCY_TOKEN_TTL` sets the scoped-token - lifetime. A revoked seat fails selection (`403`, audited `tenant.switch_denied`) - and loses access on an already-issued token via the per-request re-check. -- **Control-plane tables** (central migrations): `tenants`, `user_tenants`, - `tenant_invitations` (email onboarding, hashed token), `audit_log` (append-only). -- **Invitations** (`InvitationServiceContract`): `invite()` returns a one-time - token (hash stored); `accept()` requires the user's verified email to match, - creates/activates the seat (idempotent), audits `member.join`; `revoke()`. -- **Refresh tokens** moved to `Plugins\Auth` (`RefreshTokenServiceContract`, `POST /auth/refresh`) — tenant-agnostic; the tenant seat check stays at tenant-SELECT here. - -Env: `TENANCY_MODE` (`claim` = JWT `tnt` claim, default · `domain` = Host -sub-domain), `TENANCY_BASE_DOMAINS`, `TENANCY_REGISTRY_TTL`, -`TENANCY_BREAKER_THRESHOLD`, `TENANCY_BREAKER_COOLDOWN`, `TENANCY_TEMPLATE_PATH`, -`TENANCY_TOKEN_TTL` / `TENANCY_REFRESH_TTL` / `TENANCY_ACCESS_TTL`. -**Full AI reference: [23_TENANCY.md](23_TENANCY.md)** · human guide: -`plugins/Tenancy/README.md`. - -## DevTools (CLI) - -`make:plugin`, `make:service` (GDA scaffolding), plus introspection that reads -`module.json` as the source of truth: `module:list`, `module:info `, -`routes:list` (with collision detection), `project:list`. - ---- - -## Tests - -Unit tests for the new plugins live under `tests/Unit/Plugins/` (Crypto, -Validation, I18n, Support, Pageflow). Run `vendor/bin/phpunit`. diff --git a/docs/guides/21_CSRF.md b/docs/guides/21_CSRF.md index 4f13e84..a7436fc 100644 --- a/docs/guides/21_CSRF.md +++ b/docs/guides/21_CSRF.md @@ -165,7 +165,7 @@ Two consequences: - **Add the binding cookie to `encrypt_exempt`** (`COOKIE_ENCRYPT_EXEMPT` env or the base list in `plugins/Cookie/config/cookie.php`). It is then stored AND read as plaintext, so its raw value is byte-stable — the cleanest option for - pinning to the session cookie. See [First-party plugins → Cookie](20_FIRST_PARTY_PLUGINS.md). + pinning to the session cookie. See the [Cookie plugin](https://github.com/AlfaCode-Team/hkm-plugin-cookie). - Queue a dedicated binding cookie with `raw: true` and read it back with `$request->cookie(...)` (NOT `$this->cookie(...)`, which tries to decrypt). - Bind to a cookie that is not re-written every response (so its value never diff --git a/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md b/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md index a08229e..df65b89 100644 --- a/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md +++ b/docs/guides/22_DATA_ACCESS_ORM_BLUEPRINT.md @@ -278,5 +278,6 @@ Swoole request isolation. - `docs/guides/05_REPOSITORY.md` — repository layer rules in detail - `docs/guides/18_MIGRATIONS.md` — LetMigrate engine + patterns -- `docs/guides/19_DATABASE.md` — multi-driver Database module + DatabasePort adapter +- `hkm-plugin-database` → `docs/DATABASE.md` — the multi-driver `DatabasePort` adapter + (https://github.com/AlfaCode-Team/hkm-plugin-database) - `docs/guides/03_DOMAIN.md` — entity / value object / reconstitute() patterns diff --git a/docs/guides/23_TENANCY.md b/docs/guides/23_TENANCY.md deleted file mode 100644 index a961a40..0000000 --- a/docs/guides/23_TENANCY.md +++ /dev/null @@ -1,250 +0,0 @@ -# Tenancy Plugin — Multi-Tenant Control Plane - -> AI reference for `Plugins\Tenancy\` (solves `tenancy.routing`, **essential**). -> Database-per-tenant isolation + central control plane on top of -> `plugins/Database`'s `ConnectionManager`. Pairs with [09_SECURITY](09_SECURITY.md), -> [19_DATABASE](19_DATABASE.md), [24_USER](24_USER.md). - ---- - -## WHAT IT DOES - -Maps an incoming request to **one tenant**, then rebinds `DatabasePort` to that -tenant's **isolated database** for the request, so every repository downstream -transparently talks to the right DB. The control-plane tables (tenant registry, -memberships, invitations, hosts, audit) live in the **central** -database and are NEVER tenant-routed. - -``` -Request → identify tenant → resolve isolated DatabasePort → rebind for this request - (claim / host / cookie) (registry + breaker, fail-closed) -``` - -`requires: ["database.management"]` — module-level requires cover ONLY the -always-on `TenantContextStage` path. Everything the selection / admin / -invitation / host ROUTES need (`auth.identity`, `user.management`, -`audit.trail`, `http.pageflow`) is declared per route in `module.json` -`routes[].requires`, so a Tenancy-essential project does not register those -modules on every request. - ---- - -## TENANT IDENTIFICATION — `TENANCY_MODE` - -The pluggable `TenantIdentifier` seam decides WHICH tenant a request belongs to. -`identify(Request): string` returns the tenant id, or `''` when none was -identified — which the stage FAILS CLOSED on (404). It may also throw -`UnknownTenantException` to refuse a host explicitly (same 404). - -| Mode (`TENANCY_MODE`) | Identifier | Tenant source | -|---|---|---| -| `claim` (default, SaaS) | `ClaimTenantIdentifier` | `Identity.tenantId` (the signed JWT `tnt` claim) | -| `domain` (storefront) | `DomainTenantIdentifier` | Host sub-domain under `TENANCY_BASE_DOMAINS` | -| `host` (custom domains) | `HostTenantIdentifier` | FULL hostname via the central `tenant_hosts` registry | - -**STRICT routing — no unscoped passthrough.** Every request must resolve to a -tenant: cookie hint first, then the identifier; both empty ⇒ **404** (`Tenant -not found`). Every host the app serves must therefore be assigned to a tenant -(`tenant:host:add` in host mode; a resolvable label in domain mode). Central -control-plane code never depends on the stage skipping the rebind — it pins the -central connection explicitly via the `ConnectionManager` default. - -**Activation — must be ESSENTIAL, declared by the PROJECT.** `TenantContextStage` -is an always-on `after.load` hook that resolves `TenantIdentifier` + the -connection resolver from the **request container**; those bindings only exist -when `Tenancy::register()` ran, and the stage now FAILS LOUDLY when they are -absent. A multi-tenant project declares `"essentials": ["tenancy.routing"]` in -its `proj.json` (read by `EntryHelpers::projectEssentials()` → -`Kernel::withEssentialModules()`, which also accepts domains and fails the boot -on an unknown one). A single-tenant project leaves Tenancy OUT of `withModules` -entirely — merely dropping it from essentials would make the always-on stage -throw on every request. Essentials resolve through the dependency graph, so -Tenancy's `database.management` requirement loads with it automatically. - -**`domain` mode + session login — cross-subdomain cookie.** Control-plane routes -(`/auth/login`, `/ajx/me/tenants`, `/ajx/tenants/{id}/select`) run on the -apex/central host (`shop.localhost` → `''` → central); tenant-scoped routes run on -`.shop.localhost` (→ that tenant's DB). For the apex login's session to -carry to the tenant sub-domains, set the session cookie's domain to the shared -base: `SESSION_COOKIE_DOMAIN=.shop.localhost` (host-only otherwise = 401 on the -sub-domain). Reserved sub-domains (`TENANCY_RESERVED_SUBDOMAINS`: www, api, admin, -…) resolve to central, never a tenant. - ---- - -## REQUEST ROUTING — `TenantContextStage` (after.load, priority 5) - -`Infrastructure/Http/Stages/TenantContextStage.php`. Runs after the request -container exists, before route filters / `ExecuteStage`. - -1. Resolve the active tenant: **encrypted cookie hint first** (principal-bound), - then the `TenantIdentifier` (see cookie section). Both empty → **404 fail - closed** — there is NO unscoped passthrough to the central `DatabasePort`. -2. `resolver->for($tenantId)` → isolated `DatabasePort` (registry lookup + - per-tenant circuit breaker; **fail-closed**, no silent fallback). -3. `$container->instance(DatabasePort::class, $db)` — rebind for THIS request only. -4. `$request->withAttribute('tenant', $tenantId)` — expose to controllers. -5. `$container->bind('tenant.current', fn() => $tenantId)` — a **plain string - container key** so request-scoped services that never see the `Request` (e.g. - the User `AuditLogger`) can read the active tenant with no Tenancy import. - Use `bind()` (closure), NOT `instance()`: the kernel `ModuleContainer::instance()` - requires an `object`, so binding the bare tenant-id string there throws a - `TypeError` on every host/domain-routed request. -6. On `UnknownTenantException` → 404 (and forget a stale cookie hint); on - `TenantUnavailableException` → 403/410/503; connectivity faults feed the breaker. - -``` -✗ Binding tenant context into CoreContainer — it rides the request + request container only (Swoole-safe) -✗ Reading $_SERVER for the host inside a module — use $request->attribute('tenant') -✗ Silent fallback to central or another tenant on resolution failure — fail closed -``` - -### Tenant cookie (encrypted hint — never authority) - -`TenantContextStage` writes an **encrypted, user-bound** cookie remembering the -active tenant so a returning user keeps their selection without re-running the -picker. Properties: - -- **Encrypted** via the Cookie plugin's `EncryptionPort` (tamper → `read()` returns null). -- **Principal-bound**: stores `{t: tenantId, u: userId}`; honoured only by the - exact principal that minted it — a user's hint never replays onto another user - (or a post-logout guest), while a guest-minted hint (`u` = `''`) keeps working - for guests so public pages retain their selection. Log-in flips the principal - and re-mints. -- **Cookie first**: the remembered selection is consulted BEFORE the identifier; - the identifier only runs when there is no valid hint. Every hint is still - fully re-validated below, so a stale/hostile value can never route to an - unknown tenant. -- **Still revalidated** every request through `resolver->for()` — a hint, exactly - like the `tnt` claim. A stale hint at a deleted tenant is auto-forgotten. - ---- - -## PUBLISHED CONTRACTS (`exposes`) - -| Contract | Role | -|---|---| -| `TenantRegistryContract` | tenant_id → connection coordinates (CachePort-cached) | -| `TenantConnectionResolverContract` | `for($tenantId): DatabasePort` (+ breaker) | -| `MembershipServiceContract` | `myTenants`, `isActiveMember`, `selectTenant` | -| `InvitationServiceContract` | email invite → seat (`invite`, `accept`) | -| `TenantHostRegistryContract` | hostname → tenant_id resolution | -| `TenantHostServiceContract` | `add`/`verify`/`makePrimary`/`remove` custom hosts | - -Internal ports (`Application/Ports/`): `MembershipReader`/`MembershipWriter`, -`InvitationStore`, `TenantHostStore`, `AuditSink` (write), -`AuditReader` (read), `DnsResolver`. - ---- - -## CENTRAL TABLES (control plane — never in a tenant DB) - -| Table | Repository | Notes | -|---|---|---| -| `tenants` | `TenantRegistry` | registry; `db_password_enc` encrypted via `EncryptionPort` | -| `user_tenants` | `MembershipRepository` | M:N user↔tenant + role/status; FK → central `users`/`tenants` | -| `tenant_invitations` | `InvitationRepository` | email onboarding, hashed token | -| `tenant_hosts` | `TenantHostRepository` | PK is **`host_id`** (not `id`); custom domains + DNS verify | -| `audit_log` | write `AuditTrail` / read `AuditLogRepository` | append-only; keyset-paginated reads | - -Migrations: `plugins/Tenancy/database/migrations/`. Tenant template schema (run -per new tenant DB): `plugins/Tenancy/database/tenant-template/` (or -`TENANCY_TEMPLATE_PATH`). See [18_MIGRATIONS](18_MIGRATIONS.md). - ---- - -## AUDIT TRAIL (`audit_log`) - -Shared central table written by BOTH Tenancy and the User plugin. - -- **Write**: `AuditSink::record(action, userId?, tenantId?, meta[], ip?)` → - `AuditTrail` (best-effort — an audit write NEVER breaks the audited action). -- **Read**: `AuditReader` → `AuditLogRepository` — `recent`, `forTenant`, - `forUser`, `byAction` (keyset-paginated by descending id), `find(eventId)`, - `countForTenant`, `purgeOlderThan(cutoff)` (retention/GDPR). LIMIT is clamped + - **inlined as an int** (cannot be bound with emulated prepares off); filter - values stay parameter-bound. - ---- - -## MEMBERSHIP & SELF-SIGNUP ASSIGNMENT - -A new user is assigned to their originating tenant via the **`user.registered`** -integration event (User's transactional outbox, relayed by `user:outbox:relay`): - -``` -self-signup on tenant host → RegisterUserDTO reads request 'tenant' attribute - → UserRegisteredIntegrationEvent carries tenantId (persisted in the outbox) - → Tenancy's AssignTenantMembershipOnUserRegistered listener (subscribed in boot()) - → MembershipWriter::upsertActive(userId, tenantId, 'member') [idempotent] -``` - -- The listener resolves from the **CoreContainer** (no request context) — so the - tenant MUST ride on the event payload, never re-derived at relay time. -- The project binds the listener in the CoreContainer with a central-connection - `MembershipWriter` (the EventBus resolves listeners there). See [08_EVENTS](08_EVENTS.md). -- Assignment is **eventually consistent** (lands when the relay runs) and - **idempotent** (`upsertActive` upserts on `(user_id, tenant_id)`). - ---- - -## CLI COMMANDS (claim mode only — registered in `Provider::boot()`) - -Registered via a deferred closure that builds a scoped `ModuleContainer` -(Database + Crypto + Tenancy) so commands with module-scoped deps resolve. Hidden -in `domain` mode (tenants are provisioned by the project's own tooling there). - -| Command | Purpose | -|---|---| -| `tenant:create` | Provision: registry row → CREATE DATABASE → DB user + grant → template migrations → activate. Interactive wizard (RadioGroup driver picker, masked Password, NumberInput port) when flags are missing. **Compensating rollback** on any failure (DDL isn't transactional on MySQL). | -| `tenant:delete` | Drop the tenant DB user (all hosts), optionally the database (`--drop-database`), and the registry row. Requires confirmation / `--yes`. | -| `tenant:host:add` | Register a hostname (via `TenantHostService`); `--verified` seeds it past DNS, `--primary` makes it canonical. Prompts (tenant Select, host, IP Select) for anything omitted in a terminal. | -| `tenant:migrate` | Run tenant template migrations across the fleet (per-tenant transactional, failure-isolated, resumable). | - -### Tenant DB user provisioning (driver-aware, `ManagesTenantDatabase` trait) - -- **Privileges are scoped to the tenant's database only** — `GRANT ALL ON \`db\`.*` - (MySQL) / database `OWNER` (pgsql) / `db_owner` (sqlsrv). Never global. -- **MySQL accounts are loopback-only by default** — created at `localhost`, - `127.0.0.1`, `::1` (works over socket AND TCP); a non-loopback host pins to that - exact host. **The `'%'` wildcard is never used.** -- Supported: `mysql`/`mariadb`, `pgsql`, `sqlsrv`. `sqlite` is rejected (no - users/CREATE DATABASE — provision file-per-tenant instead). - ---- - -## TENANT SELECTION & TOKENS (HTTP, `/ajx/...`) - -- `GET /ajx/me/tenants` → list my tenants. `POST /ajx/tenants/{id}/select` → - re-verifies membership, mints a `tnt`-scoped access JWT. -- DECOMPOSED (tenancy ≠ authentication): `MembershipService` is control plane - ONLY — `selectTenant()` verifies the seat + audits and returns the verified - `TenantSummary`; it has NO Auth dependency. `TenantController` is the - composition point: it mints the token via `AuthServiceContract` (with - `roles` and the `name` claim read through User's published - `TenantProfileReaderContract`) and builds the `TenantSelection` response. - This also keeps the container graph acyclic (AuthService → UserService → - MembershipService — no cycle back into Auth). -- `POST /ajx/invitations/accept` → join a tenant from an emailed invite. -- Refresh-token rotation is NOT here — it moved to `Plugins\Auth` (`POST /auth/refresh`). Tenancy re-checks the tenant seat only at tenant-SELECT. -- Custom hosts: `GET/POST /ajx/tenant/hosts`, `…/{hostId}/verify|primary`, DELETE. - -The signed `tnt` claim is a **hint, not authority** — authorization still keys on -`(userId, tenantId, role/permission)` and membership is re-checked each request so -a revoked seat loses access before token expiry. - ---- - -## ABSOLUTE RULES - -``` -✓ Control-plane tables (tenants, user_tenants, invitations, hosts, audit_log) are CENTRAL — pin to ConnectionManager default. (refresh_tokens now belongs to Plugins\Auth.) -✓ TenantContextStage rebinds DatabasePort per request ONLY; never into CoreContainer. -✓ Tenant DB users: privileges scoped to their own database; MySQL accounts loopback/host-pinned, never '%'. -✓ Membership assignment travels on the user.registered event payload (outbox), idempotent upsert. -✓ Mint a tenant-scoped token ONLY after verifying membership; re-check every request. -✗ Reading $_SERVER / re-identifying the tenant inside a module — use $request->attribute('tenant'). -✗ Trusting the tnt claim or tenant cookie as authority — both are revalidated hints. -✗ Hand-writing CREATE USER with '@%' or cross-DB privileges in provisioning. -✗ Binding the membership/audit listener WITHOUT the project supplying its central writer in CoreContainer. -``` diff --git a/docs/guides/24_USER.md b/docs/guides/24_USER.md deleted file mode 100644 index 3c949f1..0000000 --- a/docs/guides/24_USER.md +++ /dev/null @@ -1,191 +0,0 @@ -# User Plugin — Central Identity - -> AI reference for `Plugins\User\` (solves `user.management`). -> The GLOBAL central identity store: CRUD, credential verification, email -> verification, transactional outbox. Pairs with [09_SECURITY](09_SECURITY.md) -> (Auth issues tokens over this identity), [23_TENANCY](23_TENANCY.md) (memberships -> link users to tenants), [08_EVENTS](08_EVENTS.md). - ---- - -## WHAT IT DOES - -Owns the **global, central `users` table** — identity is centralized, username -and email are globally unique. Repositories + the outbox are pinned to the -**central** connection (the `ConnectionManager` default), so identity I/O is -NEVER redirected to a tenant DB even when `TenantContextStage` rebinds -`DatabasePort` for the request. - -`requires: ["database.management", "crypto.services", "cache.redis", "view.rendering", "http.client"]` -`exposes: ["Plugins\User\API\Contracts\UserServiceContract"]` (the ONLY cross-module -contract — feedback + settings are internal to the plugin) - -**No `status` column.** The login gate is a verified email: `verifyCredentials` -checks `User::canLogin()` (= `email_verified_at` is set). "Disable" = soft delete. -The old `status` / `auth_provider` / `provider_subject` / `is_platform_admin` / -`last_login_at` columns were removed. - ---- - -## PUBLISHED CONTRACT — `UserServiceContract` - -`Application/Services/UserService.php`. All methods take/return DTOs (`API/DTOs/`) -— never entities or raw arrays across the boundary. - -| Method | Notes | -|---|---| -| `register(RegisterUserDTO): UserDTO` | tx + outbox; emits `user.registered` | -| `list(ListUsersQuery): UserPage` | paginated | -| `find(id): ?UserDTO` | | -| `update(id, UpdateUserDTO): ?UserDTO` | optimistic-locked (`version`); emits `user.updated` | -| `verifyEmail(id, VerifyEmailDTO): ?UserDTO` | | -| `verifyCredentials(identifier, password): ?UserDTO` | timing-safe, rate-limited; rehash-on-login | -| `delete(id): bool` | emits `user.deleted` | - -`RegisterUserDTO::fromRequest()` also reads the request **`tenant`** attribute -(set by Tenancy's `TenantContextStage`) into `$tenantId` — an opaque string that -is forwarded on the `user.registered` event so Tenancy can assign membership. -User stays tenant-agnostic (no Tenancy import). - ---- - -## TENANT PROFILE READS — `TenantProfileReaderContract` (published) - -`TenantProfileProvisioner` now IMPLEMENTS the published -`TenantProfileReaderContract` (`fullName(userId, tenantId): string`) in two -construction modes: **pinned** (a `UserSettingsRepository` already built -against the resolved tenant connection — the listener path) or **resolver** -(the container binding — resolves the tenant DB per call through Tenancy's -`TenantConnectionResolverContract`). Reads are BEST-EFFORT and never throw — -a missing profile / unreachable tenant DB yields `''`. Consumers: Tenancy's -tenant-selection (the JWT `name` claim) and `UserService::find()` (attaches -`UserDTO.fullName` when a membership pins the tenant). `UserDTO` also carries -`avatarUrl` and `permissions`; `UserProfile::fullName()` composes first + last. -`UserServiceContract::find()` gained `bool $isAuth = false` — skips the -self-or-permission check for issuance-time lookups by Auth (request Identity -is still guest during login). - ---- - -## SERVICE PATTERN (mandatory shape) - -Mutating methods follow the kernel transaction+event pattern (see [04_SERVICE](04_SERVICE.md)): - -``` -collector->beginCollection(); transaction->begin(); - try { entity op → flushEvents() → repository.insert() → commit(); } - catch { rollback(); collector->discard(); throw wrap(...); } -collector->release(); // domain events -audit->record('user.…', [...]); // security audit (also persisted to audit_log) -``` - -Integration events are written to the **transactional outbox** inside the tx -(durable), NOT dispatched inline. - ---- - -## EVENTS — TRANSACTIONAL OUTBOX - -`emits: ["user.registered", "user.updated", "user.deleted"]` - -- `flushEvents()` → `toIntegration()` builds the integration event and - `OutboxWriter::write()`s it into `user_outbox` **in the same transaction** as - the user change (atomic, no lost/phantom events). -- `user:outbox:relay` (CLI command, `Infrastructure/Cli/RelayUserOutboxCommand`) - drains pending rows and dispatches a `GenericIntegrationEvent` (carrying the - stored payload array) to the EventBus. Delivery is **at-least-once** → listeners - must be idempotent. -- `UserRegisteredIntegrationEvent` carries `userId, username, email, occurredAt` - **+ `tenantId`** (origin tenant for self-signup; `''` when none). This is how - Tenancy auto-assigns membership — see [23_TENANCY](23_TENANCY.md). - ---- - -## SECURITY AUDIT — `AuditLogger` - -`Infrastructure/Audit/AuditLogger.php`. Records security-relevant actions -(register, update, email verified, login failed/locked-out, password rehash, -delete) — **identifiers + outcomes only, never passwords/hashes/PII**. - -- Writes a structured JSON line (via `error_log`, tagged `source=user_audit`). -- **Also persists to the shared central `audit_log` table** when a `DatabasePort` - is injected: `userId`→`user_id`, `ip`→`ip`, the rest→JSON `meta`, `event_id` - via `Ulid::generate()`. **Best-effort** (try/catch — an audit write must never - break the audited action; the log line is the durable fallback). -- `tenant_id` is stamped from the `'tenant.current'` container key published by - Tenancy's `TenantContextStage` (`has()`-guarded — no Tenancy dependency); `NULL` - for unscoped/CLI requests. -- Reads/queries of `audit_log` are Tenancy's `AuditReader`/`AuditLogRepository`. - ---- - -## DATA - -| Table | Repository | Notes | -|---|---|---| -| `users` | `UserRepository` (central) | ULID `user_id`; unique username/email; `password_hash`, `remember_token` (60/64 char); `version` (optimistic lock); login gate = `email_verified_at` | -| `user_outbox` | `OutboxWriter` / `OutboxRelay` (central) | transactional integration-event outbox | -| `user_feedback` | `FeedbackRepository` (TENANT) | tenant-scoped; `feedback_id` UUID public id; `user_id` = central ULID (soft ref, no FK) | -| `user_profiles` / `user_preferences` / `user_privacy_settings` / `user_notification_preferences` | `UserSettingsRepository` (TENANT) | per-user singletons; one row per `user_id`; portable `upsert` | - -Central schema → `database/migrations/` (`migrate:run`). Tenant schema → -`database/tenant-template/`, applied per-tenant by the **Tenancy** tooling -(`tenant:migrate`), NOT `migrate:run`. - -- Passwords hashed via `crypto.services` (bcrypt, rehash-on-login). Hashes and - remember tokens NEVER cross the API boundary. -- `UserId`/`Ulid` value objects generate the 26-char public id. -- See [05_REPOSITORY](05_REPOSITORY.md), [18_MIGRATIONS](18_MIGRATIONS.md). - ---- - -## ROUTES (`module.json`) - -- HTML (View): `GET /users[...]`, plus demo pages `GET /account/settings`, - `/account/feedback`. -- JSON identity (`/ajx/users...`): `POST /ajx/users` register (`throttle:10,1` — - **anonymous**, not auth-gated), `GET/PUT/PATCH/DELETE /ajx/users/{id}` + - verify-email (`auth`). -- JSON feedback (`auth` + `tenant`): `POST /ajx/feedback` (`throttle:5,1`), - `GET /ajx/feedback`, `GET /ajx/feedback/{id}`, `PATCH /ajx/feedback/{id}`. -- JSON settings (`auth` + `tenant`): `GET/PUT /ajx/{profile,preferences,privacy, - notification-preferences}` (PUT `throttle:30,1`). - ---- - -## TENANT-SCOPED SUB-RESOURCES (feedback & settings) - -Internal capabilities whose data lives in the **tenant** DB (not central): - -- **Repositories take the request `DatabasePort`** (tenant-routed by - `TenantContextStage`), NOT `self::central()`. `user_id` is the central ULID, - carried as a soft reference (no cross-DB FK). -- **Routes declare `["auth", "tenant"]`.** The `tenant` filter (Tenancy plugin) - returns **409** when no tenant is active → these never silently hit central. -- **Self-scoped** — user id from `Identity`, never the body. AuthZ in the service. -- **Internal, not published** — bound `bindInternal`; controllers depend on the - concrete `FeedbackService` / `UserSettingsService`. They return the domain - **entity** and the controller serialises via `entity->toArray()` (no output DTO). -- **Feedback** = full CRUD (`submit`/`find`/`list`/`updateStatus`, forward-only - status, `feedback:manage` for triage); emits `feedback.submitted` **directly** - (single insert, not the outbox). **Settings** = one `UserSettingsService` + - `UserSettingsRepository` for the 4 singletons, idempotent `PUT` via `upsert`, - audited on write. - ---- - -## ABSOLUTE RULES - -``` -✓ users + user_outbox are CENTRAL — pin repositories to the ConnectionManager default, never the request DatabasePort. -✓ Integration events go through the transactional outbox; relayed at-least-once → idempotent listeners. -✓ Audit records identifiers/outcomes ONLY; DB persistence is best-effort and never aborts the action. -✓ Password hashes / remember tokens never appear in a DTO or response. -✓ Writes are optimistic-locked on `version`. -✓ users/feedback/settings split connections: identity = CENTRAL, feedback/settings = TENANT (request DatabasePort). -✗ Importing a Tenancy class from User — User forwards the opaque 'tenant' request attribute only. -✗ Dispatching user identity events inline instead of via the outbox (feedback.submitted is a single insert → direct dispatch is fine). -✗ Returning entities across the PUBLISHED contract (UserServiceContract) — use API/DTOs. (Internal feedback/settings services return entities; their controllers toArray().) -✗ Reading user IDENTITY from a tenant-routed DatabasePort — always central. (Feedback/settings deliberately DO use the tenant connection.) -✗ Applying tenant-template schema with migrate:run — it is per-tenant (tenant:migrate). -``` diff --git a/docs/guides/25_AUTH.md b/docs/guides/25_AUTH.md deleted file mode 100644 index 40ffb26..0000000 --- a/docs/guides/25_AUTH.md +++ /dev/null @@ -1,324 +0,0 @@ -# Auth Plugin — Authentication (tokens + sessions) - -> AI reference for `Plugins\Auth\` (solves `auth.identity`). -> Issues credentials (JWT, personal access tokens) and provides the -> SecurityLayer verifiers the kernel runs before any module loads. Pairs with -> [09_SECURITY](09_SECURITY.md), [24_USER](24_USER.md) (verifies credentials), -> [26_OAUTH2](26_OAUTH2.md) (OAuth2 access tokens are the same JWTs this layer -> verifies). - ---- - -## WHAT IT DOES - -The kernel ships **no** token validator — Auth fills the intended "AuthModule -layer" slot. It splits cleanly: - -- **Issuance** lives in `AuthService` (exposed via `AuthServiceContract`): mint - JWTs, create/revoke personal access tokens (PATs), establish/tear down web - sessions, hash/verify passwords. -- **Verification** lives in `SecurityLayer` classes a project wires into - `Kernel::withSecurity([...])`; the SecurityGateway runs them before any module - loads (deny = zero module cost). - -``` -requires: ["database.management", "crypto.services", "user.management"] -exposes: ["Plugins\Auth\API\Contracts\AuthServiceContract"] -``` -Control-plane tables (`personal_access_tokens`) are pinned to the **central** -connection. The session login flow verifies credentials via `UserServiceContract`. - ---- - -## SECURITY LAYERS (wired in the project bootstrap) - -### `JwtAuthLayer` — stateless Bearer JWT -```php -new JwtAuthLayer( - secret: $hsSecretOrPublicKeyPem, // HS secret, or PEM PUBLIC key for RS/ES/PS - algo: 'RS256', // single pinned algo — never trust the token's `alg` - issuer: env('JWT_ISSUER'), // when set, `iss` MUST match - audience: env('JWT_AUDIENCE'), // when set, `aud` MUST contain it (list-aware, hash_equals) - leeway: 60, // clock-skew tolerance for exp/iat/nbf - revocations: $cachePort, // optional jti deny-list -); -``` -- No `Authorization` header → **allow as guest** (public routes keep working). -- Valid Bearer → `Identity` from `sub`/`tnt`/`roles`/`permissions`. -- Malformed / expired / wrong iss|aud / **revoked `jti`** → `deny(401)`. -- Revocation deny-list **fails OPEN** on a cache outage (token is otherwise valid). - -### `PersonalAccessTokenLayer` — DB-backed `Bearer .` -Hashes (`sha256`) and matches against `personal_access_tokens`; **enforces -`expires_at`** (expired = absent), loads the token's `abilities` into -`Identity.permissions`, and stamps `last_used_at`. Empty `tenantId` (unscoped / -central) — consistent with the JWT layer. - -JWT/JOSE verification is the ONLY auth the kernel delegates here; everything else -(firewall, rate-limit, CSRF) is kernel-native. - ---- - -## PUBLISHED CONTRACT — `AuthServiceContract` - -| Method | Notes | -|---|---| -| `issueJwt(userId, claims, ttl): string` | adds `iat/nbf/exp/jti`, plus `iss/aud` when configured. Asymmetric algos sign with the **private key** (`JWT_PRIVATE_KEY[_FILE]`), optional `kid` | -| `revokeJwt(jti, ttl): void` | deny-lists a `jti` via `CachePort` (key `auth:jwt:revoked:`) so a token dies before expiry | -| `createPersonalAccessToken(userId, name, abilities, ttl): {id, token}` | plaintext returned ONCE; only the hash is stored; optional abilities + expiry | -| `revokePersonalAccessToken(id): void` | | -| `tokensFor(userId): list` | lists a user's PATs (newest first), **no secret material**. GDA replacement for the old `HasApiTokens::tokens()` | -| `guard(Request): Guard` | read-only projection over the request `Identity` — replaces the old `AuthManager`/named guards (see below) | -| `startSession(SessionPort, userId, roles, permissions, tenantId): void` | rotates session id (fixation defence), stores identity | -| `endSession(SessionPort): void` | invalidate + rotate | -| `hashPassword / verifyPassword` | bcrypt/argon2 via `HashingPort`, timing-safe | - ---- - -## GUARD — READ-ONLY IDENTITY PROJECTION (replaces `AuthManager`) - -There is no guard/driver factory. The SecurityGateway chain -(`JwtAuthLayer` → `PersonalAccessTokenLayer` → `SessionAuthStage`) already -resolved WHO authenticated and by WHICH credential. `Plugins\Auth\API\Guard` is a -stateless, allocation-cheap projection over the request `Identity`: - -| Method | Meaning | -|---|---| -| `check()` / `guest()` | authenticated? | -| `id()` / `tenantId()` | user id / tenant ('' = central) | -| `via()` | `'jwt' \| 'api_key' \| 'session' \| 'none'` — the "named guard", derived not chosen | -| `viaToken()` / `viaSession()` | Bearer credential vs stateful session | -| `hasRole()` / `hasPermission()` | RBAC | -| `hasScope(s)` | token scope — matches a bare permission OR OAuth2's `scope:` namespaced form | - -Controllers get it via the `Project\Http\Controllers\Concerns\InteractsWithAuth` -concern: `$this->guard()`, `$this->identity()`, `$this->authId()`, -`$this->tokenCan('write')`. Works even without the Auth module loaded (it reads -the kernel `Identity`). - ---- - -## AUTHMANAGER — NAMED GUARDS + PROVIDERS (config-driven) - -For multi-guard apps (session web + token API + jwt), `AuthManager` manages named -**guards** and user **providers** from `config/auth.php`. GDA-native rework of the -old `__DEV__` AuthManager — no global `auth.` alias, no `kernel()`/`config()` -reach-ins, and the kernel `Identity` stays the principal (guards resolve an -`AuthUserProxy` that **emits** an `Identity`). - -```php -$manager->guard(); // default guard (config defaults.guard) -$manager->guard('api')->user(); // ?Authenticatable (AuthUserProxy) -$manager->guard('jwt')->identity(); // kernel Identity -$manager->provider('users'); // a named UserProvider (ModelUserProvider) -``` - -| Piece | Role | -|---|---| -| `AuthManager` | request-scoped registry; `guard($name)`, `user()`, `check()`, `id()`, `provider($name)`. Bind `setRequest($request)` per use (Request is not container-bound) | -| `UserProvider` / `ModelUserProvider` | resolves users from a store. Default `users` provider is ModelUserProvider over `UserServiceContract` (no ORM). `retrieveByCredentials` does the FULL timing-safe verify (the store hides the hash) | -| `AuthUserProxy` | lightweight current-user; carries id/username/email + security context; `identity(): Identity`. NOT the principal | -| `GuardDriver` (`Infrastructure/Auth/Drivers/*`) | `session` (session store), `jwt`/`token` (rehydrate the SecurityGateway verdict by tokenType), `request` (credential-agnostic) | - -**Driver "scan":** `AuthManager::drivers()` filesystem-scans -`Infrastructure/Auth/Drivers/*.php` for `GuardDriver` implementations, keyed by -`driverName()`, **once per process, cached** (boot-time — a deliberate, -documented exception to the GDA no-runtime-discovery rule; never on the hot path). - -Controllers: `Project\Http\Controllers\Concerns\InteractsWithAuthManager` → -`$this->auth('api')->user()`, `$this->authUser()`. A route using it must declare -`"requires": ["auth.identity"]`. Config lives in `config/auth.php` (project copy -wins), read via `auth_config()`. - ---- - -## HIERARCHICAL SCOPE INHERITANCE - -Scopes/abilities are colon-hierarchical: a held scope satisfies every descendant. -`ScopeInheritance::satisfies($held, $required)` powers `Guard::hasScope()`, -`AuthUserProxy::tokenCan()` and `TokenDTO::can()`. - -```php -Guard::actingAs('u1', ['admin'])->hasScope('admin:users:write'); // true (ancestor) -Guard::actingAs('u1', ['reports'])->hasScope('billing'); // false -// '*' grants everything; 'scope:'-namespaced (OAuth2) and bare (PAT) both match; -// non-colon-boundary prefixes never match ('adm' ≠ 'admin'). -``` - ---- - -## PERSONAL ACCESS TOKENS — self-service (`/auth/tokens`) - -First-party user API keys (`Bearer .`), owner-scoped to the caller's -Identity. Backed by `AuthServiceContract` (hash-only storage). NOT OAuth clients, -NOT used by session login. - -| Route | Action | -|---|---| -| `GET /auth/tokens` | list my tokens (no secrets) | -| `POST /auth/tokens` | mint (plaintext returned ONCE) | -| `DELETE /auth/tokens/{id}` | revoke one of MY tokens (else 404) | - -`AuthServiceContract`: `createPersonalAccessToken`, `revokePersonalAccessToken`, -`tokensFor(userId): list`. `PersonalAccessTokenFactory` + -`PersonalAccessTokenResult` mint the one-time result. `AuthUserProxy` exposes -HasApiTokens (`tokens()/token()/tokenCan()/createToken()`). - ---- - -## REFRESH TOKENS — revocable first-party sessions (`/auth/refresh`) - -Relocated from Tenancy (authentication ≠ tenancy). `RefreshTokenServiceContract`: -`issue/rotate/revoke/revokeAllForUser`. One-time-use rotation with rotation-family -reuse detection (replay/race → burn the family → 401). Only the SHA-256 is stored; -the raw token is returned once. Table `refresh_tokens` (central, `family_id`). - -**Tenant-agnostic:** `tenantId` rides through as a scope hint for the paired -access token's `tnt` claim but is NEVER re-verified on refresh — tenant seat checks -live in the Tenancy `/ajx/tenants/{id}/select` flow. - -- `POST /auth/refresh` `{token}` → new access JWT + rotated refresh token (401 on invalid/reuse). -- `POST /auth/refresh/logout` `{token}` → revoke a single session. - -## TRANSIENT TOKEN — first-party SPA (`/auth/token/refresh`) - -`POST /auth/token/refresh` (auth-filtered). A session-authenticated SPA mints a -short-lived (900s) JWT carrying the session identity's real roles/permissions — -the scoped replacement for Passport's blanket transient token. A Bearer/PAT caller -(non-session) is refused. - -## PASSWORD RESET — `PasswordBroker` - -CachePort-backed, enumeration-safe. `sendResetLink(email)` mints a one-time hashed -token (throttled); `validateToken`; `reset(email, token, newPassword)` sets the -password (via `UserServiceContract::resetPassword`, which also clears remember -tokens) and burns the token. Statuses: `RESET_LINK_SENT` / `PASSWORD_RESET` / -`INVALID_USER` / `INVALID_TOKEN` / `THROTTLED`. - ---- - -## SESSION AUTH (web + AJAX) - -The session is opened at `after.load` (`StartSessionStage`, priority 20) — AFTER -the SecurityGateway — so session auth CANNOT be a SecurityLayer. Instead -`SessionAuthStage` is an `after.load` hook at **priority 22** (after session -start, before the route `auth` filter): - -- A request already carrying a token-derived `Identity` is left untouched (token - wins). -- An anonymous request with a logged-in session gets a `tokenType: 'session'` - Identity rebuilt from the session. -- The same `auth` route filter then protects **both** token and session callers. - -**The session Identity is bound into BOTH the request AND the request-scoped -container.** `OnDemandLoader` binds `Identity::class` at `LoadStage` from the -PRE-auth (guest) request — which runs *before* this `after.load` stage. So -`SessionAuthStage::attach()` rebinds `Identity::class` into `$request->container()` -too, not just the request. Without that rebind the `auth` route filter would pass -(it reads the request) but every **service** — which injects `Identity` from the -container — would still see a guest, so service-layer permission checks -(`requirePermission()`, `isGuest()`) would wrongly fail. Token auth is unaffected: -it attaches its Identity in the SecurityGateway (before `LoadStage`), so the -container already holds the right one. Any stage that *elevates* an Identity -mid-pipeline (adds roles/permissions) must follow the same rule — rebind the -container, not only the request. - -Endpoints (`SessionAuthController`): `POST /auth/login` (verifies via User module, -then `startSession`), `POST /auth/logout`, `GET /auth/me`. CSRF is the kernel's -`CsrfTokenLayer` (these routes are outside `/api`). - -### Post-login redirect ("previous page") - -The Session plugin's `StartSessionStage` records the last eligible page view -(GET + 2xx, HTML navigation OR a Pageflow page object via the `X-Pageflow` -response header; auth/OAuth/API/asset paths exempt, extend with -`SESSION_PREVIOUS_EXEMPT`) under **`StartSessionStage::PREVIOUS_URL`** — the -SINGLE source of truth for the key (value `auth.previous_url`; no duplicate -const anywhere). On successful `POST /auth/login`, first match wins: - -1. an explicit `redirectTo` on the login request (query or body), -2. the recorded previous page — PULLED one-time, so a fulfilled intent never - goes stale, -3. `/`. - -Browser form POSTs get a real 302; AJAX/SPA callers get `redirectTo` in the -JSON payload (alongside `user`) and navigate client-side. BOTH candidates pass -the same open-redirect guard (`safeRedirect()`): relative `/…` paths only — -`//host`, `/\` tricks and absolute URLs are rejected. SocialAuth's web -callback consumes the same key (falls back to `SOCIAL_AUTH_SUCCESS_REDIRECT`). - -### Display identity (username / email / fullName / avatarUrl) - -`Identity` carries best-effort display fields. `AuthService` fills -username/email from the central user store at issuance when the caller didn't -supply them (`displayIdentity()` → `UserServiceContract::find(id, false, -isAuth: true)` — `isAuth` skips the self-or-permission check, since at -issuance the request Identity is still guest). They ride as OIDC claims -(`preferred_username`, `email`, `name`) on JWTs — rebuilt statelessly by -`JwtAuthLayer` — and as session keys (`SESSION_USERNAME/EMAIL/NAME/AVATAR`) -for session logins/recaller resurrection. `name` (first + last) lives in the -TENANT `user_profiles` table, so only tenant-aware flows (tenant selection) -mint it. The `users` constructor dep is a **LAZY closure** (`fn(): -UserServiceContract`): an eager `make()` recurses AuthService → UserService → -MembershipService → AuthService until `max_execution_time`. - -### Remember-me (recaller cookie) - -`POST /auth/login` with `remember=true` issues an encrypted `remember_web` -cookie holding a `userId|token` **recaller** (`Plugins\Auth\Domain\ValueObjects\Recaller` -— a flat pipe string; NEVER unserialized). When a later request has no live -session, `SessionAuthStage::fromRecaller()`: - -1. reads + decrypts the cookie (via the essential `CookieJar`); -2. resolves the user by the token's SHA-256 hash (`UserServiceContract::findByRememberToken`), - rejecting a mismatched owner id or a forged/stale token; -3. re-opens the session (`startSession`, rotating the id) and attaches a - `tokenType: 'session'` Identity; -4. **rotates** the token + cookie (`cycleRememberToken`) so a stolen cookie is a - single-use window. - -Logout clears the stored token (`clearRememberToken`) and expires the cookie, so -outstanding recallers die immediately. The `remember_token` column + index live -on the central `users` table. Backed by `UserServiceContract`: -`findByRememberToken(token)`, `cycleRememberToken(userId): plaintext`, -`clearRememberToken(userId)`. - ---- - -## CLI - -- `auth:tokens:prune [--dry] [--watch=SECONDS]` — delete expired PATs (cron or a - supervised loop for no-cron environments). - ---- - -## CONFIG (env) - -`JWT_SECRET`, `JWT_ALGO` (default HS256), `JWT_ISSUER`, `JWT_AUDIENCE`, -`JWT_PRIVATE_KEY` / `JWT_PRIVATE_KEY_FILE` (asymmetric signing — file form keeps -keys off the process env), `JWT_KID`, `AUTH_PAT_TABLE`, -`AUTH_REFRESH_TTL` (refresh-token lifetime, default 30d), -`AUTH_REFRESH_ACCESS_TTL` (paired access-JWT lifetime, default 900s), -`AUTH_GUARD` / `AUTH_PROVIDER` (AuthManager defaults). Guard/provider maps live in -`config/auth.php` (read via `auth_config()`). - ---- - -## RULES - -``` -✓ Verification = SecurityLayers (gateway); issuance = AuthService. Never mix. -✓ Pin a SINGLE algo in JwtAuthLayer — never let the token's `alg` choose the verifier. -✓ Asymmetric (RS/ES/PS) for any deployment where verifiers must not hold the signing secret. -✓ PATs: store only the hash, return plaintext once, enforce expires_at, load abilities as permissions. -✓ Session login AFTER credential verification; rotate the session id (fixation defence). -✓ Guard is a projection over the request Identity — never a stateful driver/AuthManager, never a global. -✓ Remember-me: store only the token HASH, rotate on every use, match the cookie's owner id, clear on logout. -✓ Refresh tokens live in Auth, not Tenancy. One-time-use rotation; a replay/race burns the whole family. -✓ Scopes are hierarchical — an ancestor satisfies its descendants; never do a bare string-equality scope check. -✗ Re-checking tenant seat membership on refresh — refresh is tenant-agnostic; the seat check is at tenant-SELECT. -✗ A SecurityLayer that THROWS — always return a SecurityVerdict. -✗ Unserializing a recaller/cookie value — the recaller is a flat `id|token` string (object-injection safe). -✗ Trusting a `tnt` claim as authorization — it is a routing hint; authz keys on (userId, tenantId, role/permission). -✗ getenv() for JWT_* — use env() (see 11_PROJECT). -``` diff --git a/docs/guides/26_OAUTH2.md b/docs/guides/26_OAUTH2.md deleted file mode 100644 index 1438d2c..0000000 --- a/docs/guides/26_OAUTH2.md +++ /dev/null @@ -1,118 +0,0 @@ -# OAuth2 Plugin — Authorization Server (OAuth 2.1 + OIDC) - -> AI reference for `Plugins\OAuth2\` (solves `oauth.server`). -> A native, dependency-free OAuth 2.1 + OpenID Connect authorization server. -> Access tokens are JWTs signed with the platform JWT keys, so they are verified -> by [25_AUTH](25_AUTH.md)'s `JwtAuthLayer` with no extra wiring. Pairs with -> [24_USER](24_USER.md) (password grant), [09_SECURITY](09_SECURITY.md). - ---- - -## WHAT IT DOES - -A full authorization server for **third-party / delegated** access (the piece a -first-party Auth module can't provide). Reuses `firebase/php-jwt` (already a -kernel dep) — no new vendor packages, honouring native distribution. - -``` -requires: ["database.management", "crypto.services", "user.management", "view.rendering"] -exposes: ["Plugins\OAuth2\Application\Ports\ClientStore"] -``` -All control-plane tables (`oauth_clients`, `oauth_auth_codes`, -`oauth_refresh_tokens`, `oauth_scopes`, `oauth_device_codes`) are pinned to the -**central** connection. - -> **Placement:** OAuth2 is a CENTRAL/control-plane concern — serve `/oauth/*` on -> the **apex/central host**, never tenant sub-domains. In host-tenancy mode set -> `TENANCY_BASE_DOMAINS` so the apex resolves to central. - ---- - -## GRANTS - -| Grant | Notes | -|---|---| -| `authorization_code` (+ **PKCE**) | exact-match `redirect_uri`; PKCE **mandatory for public clients** (S256/plain); codes random, hashed, 60s, single-use (atomic `consume`) | -| `client_credentials` | confidential clients only; no refresh token; `sub = client_id` | -| `refresh_token` | rotating + **family reuse-detection** (replay burns the family); scope narrowing only | -| `password` | confidential client; verifies via `ResourceOwnerVerifier` (User module); deprecated by OAuth 2.1 | -| `urn:…:device_code` | RFC 8628; `authorization_pending` / `slow_down` (interval-enforced) / `access_denied` / `expired_token`; single redemption | - -Confidential clients ALWAYS authenticate (Basic or body secret, `hash_equals`); -public clients are identified by `client_id` + PKCE only. - ---- - -## ENDPOINTS - -| Method · Path | Purpose | -|---|---| -| `GET/POST /oauth/authorize` | Auth-code consent (session-auth gated; request stored **server-side**, form carries only an opaque `authz_id` — no PKCE/scope round-trip) | -| `POST /oauth/token` | token endpoint (all grants) | -| `POST /oauth/device_authorization` | device-code start (device_code + user_code) | -| `GET/POST /oauth/device` | device user-verification page | -| `GET /oauth/userinfo` | OIDC UserInfo (Bearer; requires `scope:openid`) | -| `POST /oauth/introspect` | RFC 7662 (client-authenticated) | -| `POST /oauth/revoke` | RFC 7009 — refresh family revoke **+ JWT `jti` deny-list** | -| `GET /oauth/jwks` | RFC 7517 JWKS (RSA + EC) | -| `GET /.well-known/oauth-authorization-server` · `/openid-configuration` | RFC 8414 / OIDC discovery | -| `GET /oauth/scopes` | scope catalogue **with descriptions** (`ScopeRegistry` over `ScopeStore::describe()`) — public | -| `GET/POST/PUT/DELETE /oauth/clients` · `/clients/{id}` | **self-service client mgmt** (`auth`-gated, owner-scoped via `owner_id`; secret shown ONCE on create; another owner's client → 404) | -| `GET/DELETE /oauth/authorized-tokens` · `/{id}` | **self-service authorized-apps** — list a user's active grants; delete revokes the whole rotation family (`RefreshTokenStore::findByUser`) | - -The mgmt trio is the GDA-native port of Passport's `Client`/`AuthorizedAccessToken`/ -`Scope` controllers. `ScopeRegistry` also exposes `scopesFor()`/`tokensCan()`/ -`hasScope()` for consent screens. Personal (user) API keys are NOT here — those -are Auth PATs (`/auth/tokens`); `oauth_clients` stores APPLICATIONS, not user keys. - -CSRF: the machine POSTs (`/oauth/token`, `/introspect`, `/revoke`, -`/device_authorization`) MUST be in `CsrfTokenLayer` `exemptPaths` (client-auth, -not cookie-auth); the browser consent forms (`/oauth/authorize`, `/oauth/device`) -stay CSRF-protected. - ---- - -## TOKENS - -- **Access token = JWT** signed with the platform key (`JWT_ALGO`/keys), so the - existing `JwtAuthLayer` validates it. Claims: `iss`, `aud` (the **resource - audience** `OAUTH_TOKEN_AUDIENCE` ∕ `JWT_AUDIENCE`, NOT the client), `azp` - (client), `sub`, `scope`, `jti`, and `permissions` as **`scope:`** - (namespaced so an OAuth scope can NEVER satisfy a first-party - `hasPermission('admin')`). -- **id_token** (OIDC) issued when `openid` is granted — carries `nonce`, - `aud = client_id`, `auth_time`. Refused for a **public client under symmetric - (HS) signing** (unverifiable) — OIDC needs RS/ES/PS keys. -- **Refresh token** = opaque, stored hashed, rotating. - ---- - -## CLI - -`oauth:client:create` (`--public` for PKCE clients; secret shown once), -`oauth:client:list`, `oauth:client:revoke`, `oauth:client:rotate`, -`oauth:prune [--watch=SECONDS]` (expired codes/refresh/device rows). - ---- - -## CONFIG (env) - -`OAUTH_ACCESS_TTL`, `OAUTH_REFRESH_TTL`, `OAUTH_CODE_TTL`, `OAUTH_DEVICE_TTL`, -`OAUTH_DEVICE_INTERVAL`, `OAUTH_TOKEN_AUDIENCE` (defaults to `JWT_AUDIENCE`). -Signing keys come from Auth's `JWT_*` (use **RS256 + key files** for OIDC). - ---- - -## RULES - -``` -✓ Serve /oauth/* on the apex/central host (control-plane); set TENANCY_BASE_DOMAINS in host mode. -✓ Access tokens are platform JWTs — verified by JwtAuthLayer, no OAuth-specific resource-server code. -✓ Scopes ride in `scope` AND namespaced `scope:*` permissions — never bare RBAC names. -✓ redirect_uri EXACT match, validated before any error redirect; PKCE mandatory for public clients. -✓ Refresh rotation with family reuse-detection; auth codes single-use (burned on PKCE/redirect failure). -✓ OIDC (public clients) requires asymmetric signing (RS/ES/PS) + key files. -✗ Putting OAuth scopes into bare `permissions` (collision with first-party authz). -✗ CSRF-protecting the machine token/introspect/revoke endpoints (they are client-authenticated). -✗ A new vendor OAuth package — this server is native on firebase/php-jwt. -``` diff --git a/docs/guides/27_ENTITY_SUPPORT.md b/docs/guides/27_ENTITY_SUPPORT.md deleted file mode 100644 index 60f4ed0..0000000 --- a/docs/guides/27_ENTITY_SUPPORT.md +++ /dev/null @@ -1,217 +0,0 @@ -# 27 — Entity, Casting & Hydration Support (`Project\Support\`) - -> Reusable, DI-free, I/O-free entity-mapping helpers under `projects/Support/`. -> They are the **GDA-compliant decomposition of the legacy `__DEV__/Entity` -> Active Record** — the fat CodeIgniter/Eloquent-style base was split across the -> layers it conflated, and only the genuinely reusable casting / mapping / -> entity-mechanics live here. - -This file is the AI-context summary. The exhaustive, copy-pasteable cookbooks are: - -- `projects/Support/Casting/README.md` — casting engine + hydrator (13 examples) -- `projects/Support/Entity/README.md` — the `Entity` base (18-part cookbook) - ---- - -## Why it exists - -`__DEV__/Entity/Entity.php` was a fat Active Record: magic `__get/__set`, -mutators/accessors, `save()/delete()/restore()`, `performInsert/Update`, -`getRepo_()`, WP-style meta tables, change tracking — all in one base. GDA forbids -ORM/AR in the Domain layer, entities importing infrastructure, and entities -calling their own repository. The responsibilities were therefore split: - -| Old `Entity` responsibility | GDA home | -|---|---| -| Attributes, transitions, change tracking, invariants | **Domain entity** (or the `Entity` base) | -| `save()/delete()/performInsert/Update`/meta tables | **Repository** (`DatabasePort`, tenant-scoped) | -| Type casting + row⇄object mapping | **this Support layer** | -| Mass-assignment + validation | **DTO** at the controller edge (entity keeps a guard as defense-in-depth) | -| `toArray()/jsonSerialize()` | Response **DTO** (entity provides them too) | - ---- - -## Components - -| Namespace | Class | Role | -|---|---|---| -| `Project\Support\Casting` | `DataCaster` | Cast ONE field value, either direction | -| `Project\Support\Casting` | `TypeParser` | Parse a type string into `{nullable, baseType, params}` | -| `Project\Support\Casting` | `CastInterface` / `BaseCast` | Cast contract + identity base | -| `Project\Support\Casting` | `CastException` | Invalid handler / JSON | -| `Project\Support\Casting\Casts` | 11 built-ins | see table below | -| `Project\Support\Hydration` | `DataConverter` | Map a whole DB row ⇄ object | -| `Project\Support\Entity` | `Entity` (abstract) | Enterprise base for domain entities | - ---- - -## DataCaster - -```php -new DataCaster( - ?array $castHandlers = null, // [type => CastInterface::class] merged over defaults - ?array $types = null, // [field => typeString] - ?object $helper = null, // forwarded as 3rd arg to every cast - bool $strict = true, // true: null into a non-nullable type throws -); - -$caster->castAs(mixed $value, string $field, 'get'|'set' $method = 'get'): mixed; -$caster->setTypes(array $types): static; // resets parse cache -``` - -- `'get'` = DataSource → PHP; `'set'` = PHP → DataSource. -- Prefix a type with `?` to pass `null` through. Prefer `?type` over `strict:false`. -- A field absent from `$types` is returned unchanged. - -### Type grammar (`TypeParser`) - -```text -"?"? baseType ( "[" param ( "," param )* "]" )? -``` - -`?json[array]` → nullable JSON decoded as assoc array. `datetime[ms]`, -`datetime[Y-m-d]`, `int-bool`, `csv`, etc. - -### Built-in casts (`Project\Support\Casting\Casts`) - -| Type key(s) | get (DB→PHP) | set (PHP→DB) | -|---|---|---| -| `int` / `integer` | `int` | identity | -| `float` / `double` | `float` | identity | -| `string` | `string` | identity | -| `bool` / `boolean` | `bool` (`filter_var`; `t`/`f` for PG) | identity | -| `int-bool` | `bool` | `int` (0/1) — requires bool input | -| `csv` | `string`→`array` | `array`→`string` | -| `array` | `string`→`array` (native unserialize) | `array`→`string` (`serialize`) | -| `json` | `string`→`stdClass` (or `array` with `[array]`) | value→JSON `string` | -| `object` | `(object)` cast | identity | -| `datetime` | `string`→`DateTimeImmutable` | `DateTimeInterface`→`string` | -| `timestamp` | `int`/`string`→`DateTimeImmutable` | `DateTimeInterface`→`int` | - -> `bool` casts on READ only — use `int-bool` when the column stores `0/1` and the -> WRITE must emit an int. `json[array]` → assoc array; plain `json` → `stdClass`. - -### Custom cast - -Implement `CastInterface` (or extend `BaseCast`) and register via `castHandlers` -(or the entity's `$customCasters`). Custom handlers merge over — and can override — -the defaults. - ---- - -## DataConverter (the Repository hydrator) - -```php -new DataConverter( - array $types, // [column => typeString] - array $castHandlers = [], - ?object $helper = null, - Closure|string $reconstructor = 'reconstitute', // static factory name OR closure - Closure|string $extractor = 'toRawArray', // method name OR closure -); - -$conv->fromDataSource(array $row): array; // row → PHP-typed array -$conv->toDataSource(array $php): array; // PHP → DB-typed array -$conv->reconstruct(string $class, array $row): object; -$conv->extract(object $obj): array; -``` - -Reconstruction order: closure → named static factory → throw (no reflection -back-door). Converters pool `DataCaster` by a hash of `types + castHandlers`. - ---- - -## Entity base (`Project\Support\Entity\Entity`) - -Abstract. Implements `JsonSerializable`, `ArrayAccess`, `Stringable`. All features -are infrastructure-free. - -| Area | API | -|---|---| -| Config | `$primaryKey`, `$casts`, `$customCasters`, `$fillable`, `$guarded`, `$hidden`, `$visible`, `$appends`, `$dates`, `$dateFormat` | -| Mass assignment (secure by default) | `fill()` (honours `$fillable`), `forceFill()` (bypass), `isFillable()` | -| Attribute access | `getAttribute`/`setAttribute`, `getRawAttribute`, `hasAttribute`, `only`, `except`, `get{X}Attribute`/`set{X}Attribute` hooks | -| Typed getters | `getString/getInt/getFloat/getBool/getArray/getDate` | -| Serialization | `toArray`, `toRawArray`, `jsonSerialize`, `toJson`, `__toString`, `makeHidden`/`makeVisible` | -| Change tracking | `syncOriginal`, `isDirty`, `isClean`, `wasChanged`, `getDirty`/`getChanges`, `getOriginal` | -| Identity | `getKey`, `getKeyName`, `exists`, `is`, `isNot` | -| Domain events | `recordEvent` (protected), `hasEvents`, `releaseEvents` | -| Immutability | `seal`, `isSealed` (mutation throws `LogicException`) | -| Lifecycle | `make`, `reconstitute` (records no events), `replicate` (drops PK), `__clone` resets tracking | - -### Security - -- **Mass assignment denied by default** (`$guarded = ['*']`): `fill()` only writes - `$fillable` keys, so over-posting can't set `id`/`is_admin`. Defense-in-depth — - the DTO at the controller edge is still the primary validator. -- **`__debugInfo()` redacts `$hidden`** as `********` — secrets never reach - `var_dump()`, logs or stack traces. -- **`seal()`** yields a read-only snapshot; any write throws. - ---- - -## Repository usage (NOT Active Record) - -```php -final class InvoiceRepository -{ - private DataConverter $converter; - - public function __construct( - private readonly DatabasePort $db, - private readonly Identity $identity, - ) { - $this->converter = new DataConverter( - types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], - reconstructor: 'reconstitute', - extractor: 'toRawArray', - ); - } - - public function find(string $id): Invoice - { - $row = $this->db->queryOne( - 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t', - ['id' => $id, 't' => $this->identity->tenantId], - ) ?? throw new RepositoryException("Invoice [{$id}] not found", layer: 'repository.invoice'); - - return $this->converter->reconstruct(Invoice::class, $row); - } - - public function save(Invoice $invoice): void - { - $this->db->upsert('invoices', $this->converter->extract($invoice), ['id']); - $invoice->syncOriginal(); - } -} -``` - -The Service flushes domain events inside the transaction: - -```php -$invoice->pay(); -foreach ($invoice->releaseEvents() as $event) { - $this->collector->collect($event); // buffered in-tx, discarded on rollback -} -$this->repository->save($invoice); -``` - ---- - -## Rules - -``` -✓ Entities carry data + invariants + events; Repositories carry persistence (DatabasePort). -✓ Hydrate with Entity::reconstitute($row) or DataConverter::reconstruct(); persist with toRawArray()/extract() + $db->upsert(). -✓ Casts are static + stateless (OpenSwoole-safe); DataConverter pools casters by types-hash. -✓ Mark nullable columns ?type; mass assignment is deny-by-default. -✗ save()/delete()/find()/getRepo_() on an entity — that is the Repository's job. -✗ app()/kernel()/config() or a DB query inside an entity — entities never do I/O. -✗ reconstruct() writing private props by reflection — give the entity a static reconstitute()/toRawArray(). -✗ strict:false instead of a nullable ?type. ✗ float for money — custom MoneyCast over integer cents. -``` - -Relationship to the gold standard: a `final` entity with a private constructor and -fully-encapsulated typed state is still preferred for small, well-defined -aggregates. Extend `Entity` when a flexible, meta-driven attribute bag earns its -keep. See also `03_DOMAIN.md`, `05_REPOSITORY.md`, `22_DATA_ACCESS_ORM_BLUEPRINT.md`. diff --git a/docs/guides/30_ROUTING_COOKBOOK.md b/docs/guides/30_ROUTING_COOKBOOK.md index a2bf06f..51fc4a5 100644 --- a/docs/guides/30_ROUTING_COOKBOOK.md +++ b/docs/guides/30_ROUTING_COOKBOOK.md @@ -5,7 +5,7 @@ below is what it actually produced. Copy, adjust the handler, done. Recipes live in `module.json` (a plugin) or `proj.json` (a project) — the shape is identical. See [02_MODULE.md](02_MODULE.md) for the complete key reference and -[11_PROJECT.md](11_PROJECT.md) for project-side wiring. +the [project-layer docs](https://github.com/AlfaCode-Team/hkm-project-layer/blob/main/docs/PROJECT.md) for project-side wiring. --- diff --git a/docs/guides/README.md b/docs/guides/README.md index ffdfb40..79ae69e 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -1,10 +1,14 @@ # HKM Kernel — Guides -Layer-by-layer guides to the **Gated Demand Architecture (GDA)** kernel and its first-party -plugins. Start with the overview, then dive into the layer you're working in. +Layer-by-layer guides to the **Gated Demand Architecture (GDA)** kernel. Start +with the overview, then dive into the layer you're working in. -> New here? Read the [project README](../../README.md) first for the big picture, install -> steps, and a full end-to-end feature walkthrough. +> **Scope.** These guides cover the kernel (`src/`) and the first-party packages +> it runs on (`modules/`) — and nothing else. Everything outside that documents +> itself; see [Not documented here](#not-documented-here) at the bottom. + +> New here? Read the [project README](../../README.md) first for the big picture, +> install steps, and a full end-to-end feature walkthrough. ## Architecture & lifecycle @@ -13,7 +17,7 @@ plugins. Start with the overview, then dive into the layer you're working in. | [00 · Overview](00_SENTINEL_OVERVIEW.md) | Full architecture + the request lifecycle | | [01 · Kernel](01_KERNEL.md) | Boot pipeline, materialization, the fluent builder | | [02 · Module](02_MODULE.md) | Module contract, `module.json`, on-demand loading | -| [11 · Project](11_PROJECT.md) | Project layer — wiring, domain resolution, bootstrap | +| [16 · Plugins](16_PLUGINS.md) | How the kernel loads a module from `plugins/` | ## The layers @@ -23,40 +27,55 @@ plugins. Start with the overview, then dive into the layer you're working in. | [04 · Service](04_SERVICE.md) | Transaction + event orchestration (the mandatory shape) | | [05 · Repository](05_REPOSITORY.md) | `DatabasePort` only; translate every `\PDOException` | | [06 · Gateway](06_GATEWAY.md) | Vendor SDKs only; translate vendor exceptions | -| [07 · Controller](07_CONTROLLER.md) | ≤3-line actions, DTO validation, base controllers | +| [07 · Controller](07_CONTROLLER.md) | ≤3-line actions, DTO validation, `RequestAware` | | [08 · Events](08_EVENTS.md) | Domain vs. integration events, the EventBus | ## Cross-cutting | Guide | Topic | |---|---| -| [09 · Security](09_SECURITY.md) | SecurityGateway, Identity, layers | +| [09 · Security](09_SECURITY.md) | SecurityGateway, `SecurityVerdict`, `Identity` | | [21 · CSRF](21_CSRF.md) | `CsrfTokenLayer` — HMAC-token CSRF | | [10 · Testing](10_TESTING.md) | Port fakes, service tests | | [12 · Worker](12_WORKER.md) | Worker pipeline, jobs, retry strategies | | [13 · Anti-patterns](13_ANTIPATTERNS.md) | Wrong/correct code pairs | -| [15 · Error handling](15_ERROR_HANDLING.md) | ErrorGuard + ErrorPipeline, notifiers | +| [15 · Error handling](15_ERROR_HANDLING.md) | ErrorPipeline, classifier, notifiers | +| [30 · Routing cookbook](30_ROUTING_COOKBOOK.md) | 13 worked recipes, each compiled with its real output | ## CLI & data | Guide | Topic | |---|---| | [14 · CLI](14_CLI.md) | CLI pipeline, `AbstractCommand` | -| [17 · php-io-cli](17_PHP_IO_CLI.md) | The interactive terminal component library | -| [18 · Migrations](18_MIGRATIONS.md) | LetMigrate engine, migrations, seeders | -| [19 · Database](19_DATABASE.md) | Multi-driver `DatabasePort`, connections | -| [22 · Data access blueprint](22_DATA_ACCESS_ORM_BLUEPRINT.md) | Repository/hydrator/entity mapping, portable SQL | -| [27 · Entity support](27_ENTITY_SUPPORT.md) | Casting engine, hydrator, the Entity base | +| [22 · Data access blueprint](22_DATA_ACCESS_ORM_BLUEPRINT.md) | Repository/hydrator mapping, portable SQL, no vendor ORM | + +## `modules/` — the packages the kernel runs on + +| Guide | Package | +|---|---| +| [17 · php-io-cli](17_PHP_IO_CLI.md) | `alfacode-team/php-io-cli` — the interactive terminal component library | +| [18 · Migrations](18_MIGRATIONS.md) | `alfacode-team/let-migrate` — schema engine, migrations, seeders | + +The other three (`phpshots/bind-it`, `phpshots/common-type-alias`, +`alfacode-team/http`) document themselves in their own submodules. -## Plugins +## Operations -| Guide | Plugin | +| Guide | Topic | |---|---| -| [16 · Plugins](16_PLUGINS.md) | The `plugins/` convention + local-module checklist | -| [20 · First-party plugins](20_FIRST_PARTY_PLUGINS.md) | The bundled plugin catalogue | -| [23 · Tenancy](23_TENANCY.md) | Multi-tenant routing, membership, invitations | -| [24 · User](24_USER.md) | Central identity store, outbox, audit log | -| [25 · Auth](25_AUTH.md) | JWT / PAT / session issuance + verification | -| [26 · OAuth2](26_OAUTH2.md) | OAuth 2.1 + OIDC authorization server | - -Each first-party plugin also ships its own README under `plugins//`. +| [Safe deployments](SAFE_DEPLOYMENTS_GUIDE.md) | Release and rollback runbooks | + +## Not documented here + +| Subject | Where | +|---|---| +| The `Project\` layer (`projects/`) | [hkm-project-layer](https://github.com/AlfaCode-Team/hkm-project-layer) | +| Any plugin — behaviour, API, config | that plugin's own repository: `README.md`, `CLAUDE.md`, and `module.json` as the authority | +| Which plugin claims a `solves` domain | `hkm plugins domains` — live, and never stale | +| The `hkm` CLI, bundling, installers | [`tools/README.md`](../../tools/README.md), [`tools/docs/hkm-cli-usage.md`](../../tools/docs/hkm-cli-usage.md) | +| Per-project frontend, `hkm ui`, surfaces | [`tools/src/templates/frontend/docs/HOW_IT_WORKS.md`](../../tools/src/templates/frontend/docs/HOW_IT_WORKS.md) | + +This is deliberate. A copy of someone else's documentation living in the kernel +is the copy that goes stale, and it did: the catalogue this repo used to carry +recorded a plugin's `solves` domain wrongly, claimed another had no dependencies, +and understated four plugins' `requires[]`. The manifest is the authority. diff --git a/modules/php-io-cli b/modules/php-io-cli index 04147c7..53620ec 160000 --- a/modules/php-io-cli +++ b/modules/php-io-cli @@ -1 +1 @@ -Subproject commit 04147c7465efeaf5818eb0d192826040bf25b656 +Subproject commit 53620ec587ac8cf29ce82f239e072f566d73c79c diff --git a/projects/Bootstrap/README.md b/projects/Bootstrap/README.md deleted file mode 100644 index b90972e..0000000 --- a/projects/Bootstrap/README.md +++ /dev/null @@ -1,191 +0,0 @@ -# `Project\Bootstrap` — Entry Points, Domain Resolution & Environment - -> Namespace `Project\Bootstrap\` → `projects/Bootstrap/`. - -Everything an entry point needs **before** the kernel exists: which project is -being served, which `.env` files apply, and the error net that catches failures -the kernel can never see (parse errors, fatals, a throw during bootstrap). - -All of it lives in the PROJECT layer so the kernel stays domain-agnostic and -environment-agnostic. - -``` -projects/Bootstrap/ -├── EntryHelpers.php ← shared helpers for all four entry points -├── Domain/ -│ ├── DomainType.php ← enum: Admin | Api | Project | Public -│ ├── DomainContext.php ← final readonly value object (name, path, type, host, features) -│ └── DomainResolver.php ← pure static resolve(basePath, host): ?DomainContext -└── Environment/ - ├── LoadEnvironment.php ← .env three-tier cascade loader - └── ErrorGuard.php ← pre-kernel + fatal safety net -``` - ---- - -## Entry-point order — all four entries - -```php -require vendor/autoload.php; -$domain = EntryHelpers::resolveDomain($rootPath, $host); // HTTP only (null in CLI/worker) -LoadEnvironment::load($rootPath, $domain, $argv); // 1. env FIRST -ErrorGuard::install($logRoot . '/var/logs/errors.log'); // 2. error net -$project = EntryHelpers::projectFromContext($domain); -$kernel = require EntryHelpers::bootstrapPathForContext($domain, $rootPath, $project); // 3. THEN the kernel - -$request = Request::capture(); -if ($domain !== null) { - $request = $request->withAttribute('domain', $domain); // context rides on the REQUEST -} -$kernel->http()->handle($request)->send(); -``` - ---- - -## `EntryHelpers` - -| Method | Returns | -|---|---| -| `resolveDomain(string $rootPath, ?string $host): ?DomainContext` | `null` for an empty host (CLI/worker); never throws — `null` on any registry error | -| `projectFromContext(?DomainContext $ctx): string` | context name → `HKM_PROJECT` env → `'admin'`; always sanitised | -| `bootstrapPathFor(string $rootPath, string $project): string` | `projects/{project}/bootstrap/app.php`, falling back to the legacy `bootstrap/app.php` shim | -| `bootstrapPathForContext(?DomainContext $ctx, string $rootPath, string $project): string` | honours a project registered at an EXTERNAL absolute path (a flat standalone project from `hkm new`): tries `/app/bootstrap/app.php` then `/bootstrap/app.php`, else falls back to `bootstrapPathFor()` | -| `projectRoutes(string $projectPath): array` | `proj.json` `routes[]` — method/path/handler + optional `filters`/`requires`/`name`/`faces`/`domain`/`subdomain`, for `->withRoutes(...)` | -| `projectRouteGroups(string $projectPath): array` | `proj.json` `groups[]` + source-wide `routePrefix`/`routeFilters`/`routeRequires`/`routeName`/`routeDomain`, for `->withRouteGroups(...)` | -| `projectDomains(string $projectPath): array` | `proj.json` `domains[]` — the hosts this project serves, for `->withProjectDomains(...)`. The route compiler rejects a route grouped under a host absent from this list | -| `projectEssentials(string $projectPath): array` | `proj.json` `essentials[]` — module domains/classes for `->withEssentialModules(...)` | -| `projectRoutePolicy(string $projectPath): array` | `proj.json` `routePolicy.disable[]` — for `->withRoutePolicy(...)` | - -Project names from JSON/env are validated against `/^[a-zA-Z0-9_\-]+$/` before -being concatenated into a filesystem path — traversal defence. - -Malformed entries in `proj.json` are dropped silently here **on purpose**: a typo -must not break the boot at read time. Real errors (an invalid handler, an unknown -`requires` domain, a disable spec matching nothing) are caught later by the -route-manifest compiler with a descriptive message. - ---- - -## Domain resolution - -Maps an incoming `Host` header to a `DomainContext` (project + face + features). - -### `DomainType` — the face - -`Admin = 'admin'` · `Api = 'api'` · `Project = 'project'` · `Public = 'public'`. -Add a case here when a new face needs its own routing/navigation treatment. - -### `DomainContext` — `final readonly` - -| Field | Meaning | -|---|---| -| `name` | project name from `projects.json`, or `DomainContext::PLATFORM` (`'__platform__'`) when no project matched | -| `projectPath` | absolute path to the project directory | -| `type` | resolved `DomainType` face | -| `host` | clean lowercased hostname (no port, no trailing dot) | -| `features` | feature flags from `{projectPath}/proj.json` `"features"` | - -Helpers: `isPlatformOnly()`, `isAdmin()`, `isApi()`, `isProject()`, `isPublic()`. - -### `DomainResolver::resolve(string $basePath, string $host): ?DomainContext` - -``` -1. Normalise host: lowercase, strip port, strip trailing dot, unwrap IPv6 brackets -2. Determine face from the subdomain via projects/platform.json -3. PASS 1 — exact host match against projects.json domains[] -4. PASS 2 — '.domain' suffix match against projects.json domains[] -5. Neither matched but the subdomain is admin/api → platform-only context -6. Otherwise null (the entry point falls back to HKM_PROJECT, then 'admin') -``` - -Exact match beats suffix match, so a project registering `app.example.com` -directly wins over one registering `example.com`. - -**Swoole / coroutine safety.** The context is NEVER bound into a container — it -travels on the immutable `Request` (`withAttribute('domain', …)`), so coroutines -sharing a worker cannot bleed it between in-flight requests. The resolver's cache -is a worker-level static keyed by `basePath`, populated once and never mutated on -the hot path; registry files are deploy-time artifacts and a redeploy spawns new -workers. `flushCache()` exists for tests only. - -Registry files: `projects/platform.json` (which subdomains are admin/api faces), -`projects/projects.json` (project → `domains[]`), `projects//proj.json` -(optional `features[]`, `routes[]`, `essentials[]`, `routePolicy`). - ---- - -## `LoadEnvironment` — the `.env` cascade - -`load(string $rootPath, ?DomainContext $domain = null, ?array $argv = null): void` - -``` -TIER 1 base {root}/.env, then {root}/.env.{APP_ENV|--env} -TIER 2 domain {root}/.env.{sld}, .env.{sub}, .env.{sub}.{sld} (parsed from the host) -TIER 3 project {projectPath}/.env (+ the same domain cascade) -``` - -Every file is optional; a later file overrides an earlier key. Values already -present in the REAL process environment are **never** clobbered — true OS/server -config always wins. The parser is self-contained (no `vlucas/phpdotenv`), because -native distributions ship without `vendor/`. - -### `env()` is the canonical reader — never `getenv()` in first-party code - -Values are injected into `$_ENV` and `$_SERVER` only. `putenv()` is **not** -called by default: it was ~98 % of injection cost (~1.7 µs/var) and is -coroutine-unsafe under OpenSwoole. Therefore `getenv()` does **not** see `.env` -values. - -```php -$secret = env('JWT_SECRET', ''); // ✅ correct -$secret = getenv('JWT_SECRET') ?: ''; // ✗ empty for any .env-provided key -``` - -`useProcessEnv(true)` enables process-env mirroring — only for a third-party SDK -that reads the OS env directly (AWS/Vault). - -`useCache(true)` (or `ENV_CACHE=1`) writes a compiled `var/cache/env..php` -that opcache serves, stat-invalidated by mtime+size of every examined file. Off -by default: under OpenSwoole env loads once per worker anyway, and in dev the 1 s -mtime granularity is unsafe for a rapidly-edited `.env`. `reset()` is for tests. - ---- - -## `ErrorGuard` — the outer error net - -`install(?string $logFile = null, bool $registerHandlers = true): void` - -``` -ErrorGuard (SAPI-level, pre-kernel) ── outer net ── pre-kernel throws + PHP fatals/parse/OOM - └── Kernel ErrorStage/ErrorPipeline ── inner net ── Throwables inside a running pipeline -``` - -- Forces `display_errors=off` in production and renders a generic, secret-free - 500; in debug it renders the kernel's dependency-free `DebugPageRenderer` page. -- Catches what the kernel never can — a throw during bootstrap (e.g. the `APP_KEY` - guard), parse errors, fatals, OOM. -- Both layers write to ONE log, `{project}/var/logs/errors.log`; the guard's JSON - line is tagged `source=error_guard`. It never calls the ErrorPipeline (no global - singletons) — the shared log file is the only connection. -- The debug page renders ONLY for real browser navigations; API/AJAX/JSON callers - always get JSON. It never renders in production. -- `registerHandlers: false` installs ini settings only — used under OpenSwoole. - `debugEnabled()` reports the gate; `capture(ErrorContext)` feeds it a context; - `reset()` is for tests. - ---- - -## Rules - -``` -✓ env → error guard → kernel. That order, in every entry point. -✓ DomainContext rides on the immutable Request; read it with $request->attribute('domain'). -✓ env() everywhere in first-party code — $_ENV is the source of truth, putenv() is not called. -✓ Project names from JSON/env are sanitised before touching the filesystem. -✗ Binding DomainContext into CoreContainer or ModuleContainer — coroutine leak. -✗ Reading $_SERVER['HTTP_HOST'] inside a module to get the host — use the DomainContext. -✗ Hard-coding project names in modules — read DomainContext->name. -✗ Loading env or wiring the error net inside the kernel — both are Project layer. -✗ Rendering DebugPageRenderer without an APP_DEBUG gate, or returning HTML to a JSON client. -✗ Mutating the resolver cache at runtime in production — registries are deploy-time artifacts. -``` diff --git a/projects/Http/Controllers/README.md b/projects/Http/Controllers/README.md deleted file mode 100644 index e9c07ac..0000000 --- a/projects/Http/Controllers/README.md +++ /dev/null @@ -1,177 +0,0 @@ -# `Project\Http\Controllers` — Base Controllers & Concerns - -> Namespace `Project\Http\Controllers\` → `projects/Http/Controllers/`. - -Optional base classes + traits that give controllers a consistent response -envelope and ergonomic access to request-scoped infrastructure (session, cookies, -CSRF, storage, project context, auth, SEO). - -They live in the **PROJECT layer, not the kernel** — view rendering, cookies and -sessions are plugin concerns, and the kernel must not couple to a plugin. The -ONLY kernel↔controller seam is the `RequestAware` contract. - -Controllers still obey the Five Access Rules: **3 lines maximum** — build a DTO, -call a published service contract, translate the result to a `Response`. These -helpers exist so that translation stays one line, not so logic can move in. - ---- - -## `RequestAware` — actions take route params only - -Both bases implement `Kernel\Http\Contracts\RequestAware` (`setRequest(Request): static`, -provided by the `HasRequest` trait). `ExecuteStage` checks `instanceof RequestAware` -and, when true, calls `setRequest($request)` with the container-bearing request and -invokes the action as `$method(...$routeParams)` — **without `$request`**. - -```php -final class CartController extends ApiController // RequestAware -{ - public function show(string $id): Response // route param only — no $request - { - $this->queueCookie('last_viewed', $id); // request injected by the kernel - return $this->okOrNotFound($this->cart->find($id)?->toArray()); - } -} -``` - -Plain controllers (not extending these bases) keep the classic -`$method($request, ...$params)` signature — fully backward compatible. Inside a -`RequestAware` controller the raw request is `$this->request`; every helper also -accepts an explicit `?Request` override as its last argument. - ---- - -## `ApiController` — JSON endpoints - -`abstract class ApiController implements RequestAware`, composing -`InteractsWithCsrf` (which pulls in `HasRequest` + `InteractsWithCookies`) and -`InteractsWithSession`. Pure kernel-typed surface — no plugin or vendor coupling. - -Every success is `{"data": …}`; every failure is the kernel error envelope -`{"error": {"code","message"[,"fields"]}}`. - -| Helper | Result | -|---|---| -| `ok($data = null, $status = 200)` | `200 {"data": …}` | -| `created($data, ?$location)` | `201` + `Location` header | -| `accepted($data = null)` | `202` — queued/async work | -| `noContent()` | `204` | -| `paginated($items, $total, $page, $perPage)` | `200 {"data": …, "meta": {total, page, per_page, pages}}` | -| `okOrNotFound($data, $message)` | `200` when non-null, `404` when null | -| `notFound($message)` / `forbidden($message)` | `404` / `403` | -| `unprocessable(array $errors, $message)` | `422` + field errors | -| `identity(?Request)` | attached `Identity`, or `Identity::guest()` | - -## `ViewController` — HTML endpoints - -`abstract class ViewController implements RequestAware`, same traits, plus an -injected `Plugins\View\API\Contracts\ViewRendererContract`: - -```php -final class HomeController extends ViewController -{ - protected const API_BASE = '/api'; // exposed to templates as $apiBase - - public function index(): Response - { - return $this->view('home', ['title' => 'Welcome'], layout: 'layouts/app'); - } -} -``` - -| Helper | Result | -|---|---| -| `view($view, $data = [], ?$layout, $status = 200)` | HTML response; injects `$data['csrf']` (from `InteractsWithCsrf`) and `$data['apiBase']` (`static::API_BASE`) | -| `viewNotFound($view, $data = [], ?$layout)` | the same render at status `404` | -| `redirect($url, $status = 302)` | redirect | -| `back(?$referer, $fallback = '/')` | redirect to referer with a safe fallback | - -A route using `ViewController` must load the View plugin — -`"requires": ["view.rendering"]` on the route, or the plugin's own module route. - ---- - -## Concerns - -All traits compose on one controller: they share `HasRequest`, which is flattened -once so there is no trait conflict. - -### `HasRequest` -Holds `$request` + `setRequest(Request): static`; `resolveRequest(?Request)` -returns the explicit override or the injected request, throwing `KernelException` -when neither exists. Every other concern builds on it. - -### `InteractsWithSession` — `SessionPort` -`session()`, `sessionGet()`, `sessionPut()`, `sessionHas()`, `sessionPull()`, -`sessionForget()`, `flash()`, `csrfToken()`, `regenerateSession()` (call right -after login — session-fixation defence), `invalidateSession()` (logout). -Read helpers **no-op / return the default** when the Session plugin is absent. - -### `InteractsWithCookies` — `CookieJar` -`cookie()` (read the RAW request cookie), `queueCookie()`, `rememberCookie()`, -`forgetCookie()`, `hasQueuedCookie()`, `decryptCookie()`, `cookieJar()`. -Defaults come from `config/cookie.php` + `COOKIE_*` env. Never call -`CookieJar::applyTo()` yourself — `QueuedCookiesStage` flushes the jar. - -### `InteractsWithCsrf` — kernel `CsrfTokenLayer` -Mints tokens bound to a per-client binding cookie (`CSRF_BIND_COOKIE`, default -`csrf_bind`), stored **raw** (unencrypted) and `httpOnly` so the layer's -header-time read matches. `_csrfToken()` returns the HMAC token built from -`APP_KEY` + binding + `CSRF_LIFETIME` (default `LIFETIME = 43200`s) + the -optional `$csrfAction` scope; it returns `''` fail-closed when `APP_KEY` is -missing. `ViewController::view()` injects it as `$data['csrf']`. - -> The binding cookie MUST be listed in the Cookie plugin's `encrypt_exempt`, and -> `CSRF_LIFETIME` MUST match the `CsrfTokenLayer` lifetime in `withSecurity([...])`. -> See `docs/ai-context/21_CSRF.md`. - -### `InteractsWithProject` — `DomainContext` -Reads the context off the request (`Request::attribute('domain')` — never the -container): `project()`, `requireProject()`, `projectName()`, `projectPath()`, -`projectFace()`, `projectHost()`, `isAdmin()`, `isApi()`, `isProject()`, -`isPublic()`, `isPlatformOnly()`, `projectFeatures()`, `hasFeature()`, -`feature()`. Read helpers degrade to `null`/`false`/`[]` when no context is -attached (CLI/worker); `requireProject()` throws `KernelException`. - -### `InteractsWithStorage` — `StoragePort` -`storage()`, `storageAvailable()`, `storeUpload()` (random name), -`storeUploadAs()` (keeps the client name), `storeBase64()`, `storeContents()`, -`readFile()`, `fileExists()`, `fileUrl()` (signed temporary URL), `deleteFile()`, -`copyFile()`, `moveFile()`. Read helpers return `null`/`false` when Storage is -absent; **write helpers throw** — a missing backing store is a real fault. A -route using these declares `"requires": ["storage.local"]`. - -### `InteractsWithAuth` — lightweight `Identity` projection -`guard()`, `identity()`, `authCheck()`, `authId()`, `tokenCan($scope)`. Read-only -view of who the caller is; no user record is loaded. - -### `InteractsWithAuthManager` — full `AuthManager` -`authManager()`, `auth(?string $guard)` (a `GuardAccessor`), `authUser(?string $guard)` -(an `Authenticatable`). Use this when you need the actual user model, named -guards, or `attempt()/login()/logout()` — not just the identity projection. - -### `InteractsWithSeo` -`siteBaseUrl()`, `routeCatalog()`, `sitemap()`, `sitemapFromRoutes()`, -`openGraph($type, ?$title)`, `ogImage($url, $w, $h, $alt)`, `richGraph()`, -`robots($encoding)`. - -### `InteractsWithGraphSeo` -Composes `InteractsWithSeo` and adds `graph()`, `seoHead()`, `seoFor(...)` and -`seoPrivate($title)` (a `noindex` head for authenticated pages). See -[`../../Support/Seo/README.md`](../../Support/Seo/README.md). - ---- - -## Rules - -``` -✓ Extend ApiController for JSON, ViewController for HTML — one envelope shape per surface. -✓ RequestAware actions take ROUTE PARAMS only; reach the request via $this->request. -✓ Call regenerateSession() after login and invalidateSession() on logout. -✓ Declare the plugins a controller's helpers need in the route's requires[] (view.rendering, storage.local, …). -✗ Adding $request to a RequestAware action signature — it receives route params only. -✗ Business logic in a controller — 3 lines: DTO → service contract → Response. -✗ Calling CookieJar::applyTo() manually — QueuedCookiesStage already flushes queued cookies. -✗ Coupling the kernel to these bases — RequestAware is the only sanctioned seam. -✗ Injecting a Repository into a controller — controllers talk to published service contracts only. -``` diff --git a/projects/Infrastructure/README.md b/projects/Infrastructure/README.md deleted file mode 100644 index 1ab1a51..0000000 --- a/projects/Infrastructure/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# `Project\Infrastructure` — Project-Supplied Port Adapters - -> Namespace `Project\Infrastructure\` → `projects/Infrastructure/`. - -The kernel defines **port interfaces**; the project supplies the -**implementations** and binds them in `bootstrap/app.php` via `->withPorts([...])`. -These adapters are the dependency-free defaults, so the framework boots and runs -end-to-end with **zero external infrastructure** — no MySQL, no Redis, no broker. - -They are deliberately modest: correct, daemon-free defaults for dev, CI and small -deployments. Point them at real infrastructure (or let a plugin override the -binding) in production. - -| Class | Implements | Default for | -|---|---|---| -| `PdoDatabase` | `DatabasePort` | SQL access via PDO (SQLite in-memory by default) | -| `LazyDatabasePort` | `DatabasePort` | Defers building the real adapter until first use | -| `FileCache` | `CachePort` | Cross-process cache on disk | -| `InMemoryCache` | `CachePort` | Per-process cache (no persistence) | -| `FileLock` | `Lock` (`AbstractLock`) | Cross-process lock (single machine) | -| `ProcessLocalLock` | `Lock` (`AbstractLock`) | Single-process lock — **not distributed** | -| `FileQueue` | `QueuePort` | Cross-process job queue on disk | -| `LazyMailPort` | `MailPort` | Defers building the mailer/SMTP transport | - ---- - -## `PdoDatabase` — the `DatabasePort` default - -```php -DatabasePort::class => new PdoDatabase( - dsn: env('DB_DSN', 'sqlite::memory:'), - username: env('DB_USERNAME'), - password: env('DB_PASSWORD'), -), -``` - -Implements the whole port: `query()`, `queryOne()`, `execute()`, `upsert()`, -`lastInsertId(?string $sequence)`, `beginTransaction()`, `commit()`, `rollback()`, -`inTransaction()`. - -- `upsert()` compiles the **driver-correct** statement — `ON DUPLICATE KEY UPDATE` - on MySQL, `ON CONFLICT … DO UPDATE` on PostgreSQL/SQLite. Never hand-write - either clause in a repository; call `$db->upsert()`. -- `\PDOException` never escapes a repository — translate it to - `RepositoryException` at the repository boundary. -- **Swoole:** one instance per worker, created in `bootstrap/app.php`, alive for - the worker's lifetime. PDO handles are not shared across workers, so this is - safe. It is not a static singleton. - -## `LazyDatabasePort` / `LazyMailPort` — build on first use - -Both wrap a `Closure` factory and memoise the resolved port, so wiring a -notifier does not force a connection at bootstrap: - -```php -DatabasePort::class => new LazyDatabasePort( - static fn (): PdoDatabase => new PdoDatabase(env('DB_DSN'), …), -), -``` - -`DatabaseErrorLogger` and `MailNotifier` only touch their backend when an error -of the configured severity actually fires — on the happy path neither the DB -connection nor the SMTP transport is ever opened. Every port method proxies -through to the resolved instance. - ---- - -## Cache adapters - -### `FileCache` — cross-process, the correct default - -One file per key under the directory you pass (e.g. `var/cache/data`), holding a -serialized `{key, value, expires}` record. - -```php -CachePort::class => new FileCache($projectRoot . '/var/cache/data'), -``` - -Entries survive the end of a request, which is what a password-reset OTP, a reset -token or a rate-limit counter actually requires: under PHP-FPM the write and the -read happen in **different processes**, so an in-memory store makes every one of -them fail on first read — looking exactly like an expired entry. - -Full port: `get`, `set`, `delete`, `has`, `remember`, `increment`, -`deletePattern`, `flush`, plus `lock()` / `restoreLock()` returning a -[`FileLock`](#filelock--cross-process-lock). - -Trade-offs: every write takes an exclusive lock, and `deletePattern()`/`flush()` -scan the directory — not a high-throughput cache. Values use `serialize()`, so -the directory must be trusted, application-owned storage under `var/`, never in -the docroot. Set `REDIS_HOST` in production; the RedisCache plugin overrides the -binding. - -### `InMemoryCache` — per-process - -```php -CachePort::class => static fn(): InMemoryCache => new InMemoryCache(), -``` - -Same port surface, state held in an array. State is **per worker** and dies with -the request under FPM. Use it for tests, single-process CLI runs and caches whose -loss is harmless — never for anything that must be read back by a later request. -Its `lock()` returns a `ProcessLocalLock`, which is exactly as strong as the -store behind it. - ---- - -## Locks - -### `FileLock` — cross-process lock - -Acquire is `fopen($file, 'x')` (`O_CREAT|O_EXCL`), which the kernel guarantees is -atomic on a local filesystem: exactly one caller creates the file, every other -gets `false`. Release takes `flock(LOCK_EX)` around read-owner-then-unlink so the -compare and the delete cannot interleave with another process acquiring after the -TTL expired. API: `acquire()`, `release()`, `forceRelease()` (+ the `AbstractLock` -helpers). - -Limits — read before deploying: - -- `O_EXCL` is **not reliable on NFS**. On a shared/network filesystem use Redis. -- An expired lock is reclaimed lazily by the next acquirer, so a crashed holder - blocks others until its TTL passes — **always pass a real TTL**. - -### `ProcessLocalLock` — ⚠ not a distributed lock - -Valid only inside one PHP process. Under PHP-FPM two concurrent requests are two -processes, so both "acquire" the same lock and both enter the critical section; -under OpenSwoole it holds within one worker and fails across workers. It exists -because `CachePort` must implement the whole contract, and it is genuinely -correct for single-process test and CLI runs. Use `FileLock` (single machine) or -the RedisCache plugin's `RedisLock` (cross-machine) for anything real. - ---- - -## `FileQueue` — the `QueuePort` default - -Jobs are appended as JSON lines to one file per queue under the directory you -pass (e.g. `var/queue`). - -```php -QueuePort::class => new FileQueue($projectRoot . '/var/queue', defaultMaxAttempts: 3), -``` - -Implements the full lifecycle — `push()`, `later()`, `size()`, `pop()`, `ack()`, -`release()`, `fail()` — and rebuilds a `JobPayload` from the stored record, so a -job pushed by a web request is popped by a separate `php app/worker/run.php` -process. That is enough to run the worker end to end without Redis/SQS. - -Not a broker: writes take an exclusive lock and `pop()` rewrites the file. Swap -for the Redis-backed adapter in production (it overrides this binding when -`REDIS_HOST` is set) — the worker entry point needs no change. - ---- - -## Rules - -``` -✓ The kernel owns the port INTERFACE; the project owns the IMPLEMENTATION and binds it in withPorts(). -✓ These are dependency-free defaults so the framework boots with no external services. -✓ Cross-request state (OTPs, reset tokens, rate limits) needs FileCache or Redis — never InMemoryCache. -✓ Pass a real TTL to any lock; a crashed holder is only released when the TTL passes. -✓ One adapter instance per worker, created in bootstrap — never a static singleton. -✗ ProcessLocalLock for anything concurrent — it races silently in production. -✗ Hand-written ON DUPLICATE KEY / ON CONFLICT in a repository — call $db->upsert(). -✗ Letting \PDOException escape a repository — translate it to RepositoryException. -✗ Putting the cache/queue directories anywhere readable by the web server — they live under var/. -✗ Business logic in an adapter — it translates a port call to a backend call, nothing more. -``` diff --git a/projects/README.md b/projects/README.md index fefef90..e578ccc 100644 --- a/projects/README.md +++ b/projects/README.md @@ -1,122 +1,8 @@ -# `projects/` — The Project Layer +# `projects/` — the Project Layer -> Namespace `Project\` → `projects/` (composer psr-4 `"Project\\": "projects/"`). +The documentation for this layer lives in its own repository: +**[hkm-project-layer](https://github.com/AlfaCode-Team/hkm-project-layer)**. -The third of the Three Worlds: - -``` -┌─────────────────────────────────────────────────┐ -│ PROJECT LAYER (wiring only — no business logic)│ -│ ┌─────────────────────────────────────────────┐ │ -│ │ MODULE / PLUGIN LAYER (bounded domains) │ │ -│ │ ┌───────────────────────────────────────┐ │ │ -│ │ │ KERNEL (boot, security, DI, ports) │ │ │ -│ │ └───────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ -``` - -The project **knows everything and contains no business logic**. It decides which -project is served, which plugins are active, which port implementations back the -kernel's interfaces, and how output is shaped — nothing more. Domain logic lives -in `plugins/`. - -| Where | For | -|---|---| -| `src/` (repo root) | Kernel internals — never project or business logic | -| `plugins/` | Reusable business modules (full GDA, `Plugins\` namespace) | -| `projects/` | Wiring, adapters, shared project-layer support (`Project\`) | -| `projects//src/` | Logic for ONE project only (`Projects\\`) — not reusable | - ---- - -## Contents - -| Path | Namespace | Role | Doc | -|---|---|---|---| -| `Bootstrap/` | `Project\Bootstrap\` | Entry-point helpers, domain resolution, env loading, pre-kernel error net | [README](Bootstrap/README.md) | -| `Http/Controllers/` | `Project\Http\Controllers\` | Base controllers (`ApiController`, `ViewController`) + concern traits | [README](Http/Controllers/README.md) | -| `Infrastructure/` | `Project\Infrastructure\` | Dependency-free port adapters (PDO, file cache/queue/lock, lazy ports) | [README](Infrastructure/README.md) | -| `Support/` | `Project\Support\` | DI-free helpers: `Arr`, `Str`, `Collection`, `Resource`, casting, hydration, `Entity`, SEO | [README](Support/README.md) | -| `platform.json` | — | Subdomain registry: which subdomains are the admin / api face | [Bootstrap README](Bootstrap/README.md) | -| `projects.json` | — | Project registry: name → version, path, `domains[]` | [Bootstrap README](Bootstrap/README.md) | -| `/` | `Projects\\` | A registered project: `bootstrap/`, `config/`, `src/`, `app/`, `database/`, `var/`, `userdata/` | `docs/ai-context/11_PROJECT.md` | - -Sub-docs for the deeper Support areas: -[`Support/Casting/README.md`](Support/Casting/README.md) · -[`Support/Entity/README.md`](Support/Entity/README.md) · -[`Support/Seo/README.md`](Support/Seo/README.md). - -A registered project may live **outside this directory** — `projects.json` records -an absolute `path`, so a standalone project created with `hkm new` is booted from -its own tree (`EntryHelpers::bootstrapPathForContext()` handles both layouts). - ---- - -## Registries - -```jsonc -// projects/platform.json — which subdomain means which face -{ "subdomains": { "admin": ["app", "admin"], "api": ["api"] } } - -// projects/projects.json — host → project -{ - "shop": { - "name": "shop", "version": "1.0.0", - "path": "/abs/path/to/psp-shop", - "domains": ["shop.com"] - } -} -``` - -Both are **deploy-time artifacts**: `DomainResolver` caches them per worker and -never invalidates on the hot path. A redeploy spawns new workers. - ---- - -## Per-project layout - -| Path | Role | -|---|---| -| `projects//bootstrap/app.php` | Extends the shared base builder, adds this project's modules/ports, calls `->build()` | -| `projects//proj.json` | `features[]`, `routes[]`, `essentials[]`, `routePolicy.disable[]` | -| `projects//config/` | Project configuration files (deep-merged over plugin config at boot) | -| `projects//src/` | Project-only PHP under `Projects\\` — wiring glue, project-only services/listeners/commands | -| `projects//app/` | Project-local entry points for a standalone deploy (docroot = `app/public_html`) | -| `projects//database/` | Project migrations / seeders / factories (LetMigrate) | -| `projects//var/` | Ephemeral runtime: logs, cache, compiled manifests, tmp, locks | -| `projects//userdata/` | Persisted tenant data: uploads, reports, exports | - -`Paths::var()/logs()/cache()/userdata()/config()` resolve under the project root -when `withProjectPath()` is set, and under the base roots otherwise. - ---- - -## What the project layer decides - -1. **Which project** serves a request — `DomainResolver` (Host header) → - `HKM_PROJECT` → `'admin'`. -2. **Which ports** back the kernel interfaces — `->withPorts([...])` with the - adapters in `Infrastructure/` (or a plugin's). -3. **Which plugins** are active — `->withModules([...])`, plus - `->withEssentialModules([...])` / `proj.json` `essentials[]` for the always-on - ones. -4. **Which routes exist** — `proj.json` `routes[]` (project routes override plugin - routes) and `routePolicy.disable[]` (veto a plugin route without forking it). -5. **What the environment is** — the `.env` cascade and the pre-kernel error net. - ---- - -## Rules - -``` -✓ Wiring only — every business rule belongs to a plugin. -✓ Project resources (routes, views) override plugin resources by default. -✓ Port implementations are bound in bootstrap and injected — never instantiated inside a module. -✓ Support/ classes are DI-free and safe to call from any layer. -✗ Business logic in projects/ — if it is reusable it is a plugin, if it is project-only it goes in projects//src/. -✗ New local modules under projects/ — use plugins/ with the Plugins\ namespace. -✗ Routes defined in PHP — declare them in proj.json / withRoutes(). -✗ DomainContext in a container — it rides on the immutable Request. -✗ Kernel code importing anything from Project\ — the dependency only points inward. -``` +This repository's documentation covers the kernel (`src/`) and the packages it +runs on (`modules/`) — and nothing else. The directory layout there mirrors this +one, so a path here maps to the same path in that repo. diff --git a/projects/Support/Casting/README.md b/projects/Support/Casting/README.md deleted file mode 100644 index 3cac873..0000000 --- a/projects/Support/Casting/README.md +++ /dev/null @@ -1,481 +0,0 @@ -# Casting & Hydration (`Project\Support\Casting` + `Project\Support\Hydration`) - -Dependency-free, DI-free type-casting and row-hydration utilities, ported from -the legacy `__DEV__/DataCaster`, `__DEV__/DataConverter` and the `__DEV__/Entity` -Active-Record base class — refactored to obey the GDA layer rules. - -- **Namespaces:** `Project\Support\Casting`, `Project\Support\Casting\Casts`, - `Project\Support\Hydration` -- **Autoload:** `Project\` → `projects/` (PSR-4, already in `composer.json`) -- **No dependencies:** no Carbon, no `BaseConnection`, no WordPress helpers, no - container, no globals — pure value transformation, safe to use from any layer. - ---- - -## Table of contents - -1. [Why the old `Entity` was decomposed](#why-the-old-entity-was-decomposed) -2. [What was dropped / changed in the port](#what-was-dropped--changed-in-the-port) -3. [Components at a glance](#components-at-a-glance) -4. [`DataCaster` — the engine](#datacaster--the-engine) -5. [Type-string grammar (`TypeParser`)](#type-string-grammar-typeparser) -6. [Built-in casts](#built-in-casts) -7. [Custom casts](#custom-casts) -8. [`DataConverter` — the hydrator](#dataconverter--the-hydrator) -9. [Cookbook — exhaustive examples](#cookbook--exhaustive-examples) -10. [Design notes & caveats](#design-notes--caveats) - ---- - -## Why the old `Entity` was decomposed - -`__DEV__/Entity/Entity.php` is a CodeIgniter/Eloquent-style **fat Active Record**: -magic `__get/__set`, mutators/accessors, `save()/delete()/restore()`, -`performInsert/Update`, `getRepo_()`, WP-style meta tables and change tracking — -all in one base class. GDA explicitly forbids this: - -> ✗ Eloquent, Active Record, or any ORM in the Domain layer -> ✗ Domain importing anything external · ✗ an entity calling its own repository - -So the single class is split across the layers it was conflating: - -| Old `Entity` responsibility | GDA home | -| --- | --- | -| Attributes, state transitions, change tracking, invariants | **Domain entity** (`final`, private ctor, `create()`/`reconstitute()`, `releaseEvents()`) — or the [`Entity` base](../Entity/README.md) | -| `save()` / `delete()` / `performInsert/Update` / meta tables | **Repository** (`DatabasePort` only, tenant-scoped SQL) | -| Type casting on read/write, DB-row ⇄ object mapping | **this Support layer** (`DataCaster` + `DataConverter`) | -| Mass-assignment (`fillable`/`guarded`), validation | **DTO** (`fromRequest()` validation) at the controller edge | -| `toArray()` / `jsonSerialize()` | Response **DTO** `toArray()` | - -The casting/mapping concern is the only genuinely reusable, rule-compliant piece, -so it lives here. The rest is per-domain code that belongs in each plugin. - -## What was dropped / changed in the port - -- **No Carbon / `BaseConnection`** — `DatetimeCast` and `TimestampCast` use - `\DateTimeImmutable` (the framework's Domain date type). -- **No WP `maybe_serialize`** — `ArrayCast` uses native `serialize()` with - `allowed_classes => false` on read. -- **Dropped legacy-VO casts** (`StatusCast`, `ProjectStatusCast`, `URICast`) — - they coupled to `HKM\lib\Common\ValueObjects\*` which is not part of this - framework. Re-add them per-domain as custom handlers (see below). -- **`DataConverter` no longer touches `Entity` or `kernel()`** — it reconstructs - via an explicit static factory (`reconstitute` by default) or a closure, with - no reflection back-door. - ---- - -## Components at a glance - -| Class | Role | -| --- | --- | -| `Casting\DataCaster` | Casts one field value, either direction (`get`/`set`) | -| `Casting\TypeParser` | Parses a type string into `{nullable, baseType, params}` | -| `Casting\CastInterface` | The contract every cast implements (`get`/`set`) | -| `Casting\BaseCast` | Identity cast; subclass and override one direction | -| `Casting\CastException` | Thrown on invalid handler / JSON | -| `Casting\Casts\*` | The 11 built-in casts | -| `Hydration\DataConverter` | Maps a whole DB row ⇄ object (uses `DataCaster`) | - ---- - -## `DataCaster` — the engine - -```php -use Project\Support\Casting\DataCaster; - -new DataCaster( - ?array $castHandlers = null, // custom [type => CastInterface::class], merged over defaults - ?array $types = null, // [field => typeString] - ?object $helper = null, // passed as 3rd arg to every cast (e.g. a connection) - bool $strict = true, // true: passing null to a non-nullable type throws -); -``` - -| Method | Returns | Notes | -| --- | --- | --- | -| `setTypes(array $types)` | `static` | Replace the field→type map (clears the parse cache) | -| `castAs(mixed $value, string $field, 'get'\|'set' $method='get')` | `mixed` | Cast `$value` for `$field`; unknown field → returned unchanged | - -Direction: - -- `'get'` = **DataSource → PHP** (reading a DB row) -- `'set'` = **PHP → DataSource** (writing to the DB) - -Nullability & strictness: - -- Prefix a type with `?` to let `null` pass through untouched in either direction. -- `strict: true` (default): passing `null` to a **non**-nullable type throws - `InvalidArgumentException`. -- A field with no entry in `$types` is returned verbatim (no-op). - -```php -$caster = new DataCaster(types: [ - 'id' => 'int', - 'price' => 'float', - 'active' => 'bool', - 'tags' => 'csv', - 'meta' => '?json[array]', - 'created' => 'datetime', -], strict: false); - -$caster->castAs('42', 'id'); // 42 (get) -$caster->castAs('a,b', 'tags'); // ['a', 'b'] (get) -$caster->castAs(null, 'meta'); // null (nullable) -$caster->castAs($dateTime, 'created', 'set');// 'YYYY-mm-dd H:i:s' (set) -$caster->castAs('whatever', 'unknown'); // 'whatever' (no type → no-op) -``` - ---- - -## Type-string grammar (`TypeParser`) - -```text -"?"? baseType ( "[" param ( "," param )* "]" )? -``` - -| Input | nullable | baseType | params | -| --- | --- | --- | --- | -| `int` | `false` | `int` | `[]` | -| `?string` | `true` | `string` | `[]` | -| `json[array]` | `false` | `json` | `['array']` | -| `?datetime[ms]` | `true` | `datetime` | `['ms']` | -| `datetime[Y-m-d]` | `false` | `datetime` | `['Y-m-d']` | - -```php -use Project\Support\Casting\TypeParser; - -TypeParser::parse('?json[array]'); -// ['nullable' => true, 'baseType' => 'json', 'params' => ['array']] -``` - -You rarely call this directly — `DataCaster` uses it internally and caches the -result per field. - ---- - -## Built-in casts - -All live in `Project\Support\Casting\Casts`. "get" = DB→PHP, "set" = PHP→DB. -Casts with an identity "set" (BaseCast default) store the value unchanged. - -| Type key(s) | Class | get (DB → PHP) | set (PHP → DB) | -| --- | --- | --- | --- | -| `int`, `integer` | `IntegerCast` | `int` | _identity_ | -| `float`, `double` | `FloatCast` | `float` | _identity_ | -| `string` | `StringCast` | `string` | _identity_ | -| `bool`, `boolean` | `BooleanCast` | `bool` (`filter_var`; `t`/`f` for PG) | _identity_ | -| `int-bool` | `IntBoolCast` | `bool` | `int` (0/1) — requires bool input | -| `csv` | `CSVCast` | `string` → `array` (split `,`) | `array` → `string` (join `,`) | -| `array` | `ArrayCast` | `string` → `array` (native unserialize) | `array` → `string` (`serialize`) | -| `json` | `JsonCast` | `string` → `stdClass` (or `array` w/ `[array]`) | value → JSON `string` | -| `object` | `ObjectCast` | `(object)` cast | _identity_ | -| `datetime` | `DatetimeCast` | `string` → `DateTimeImmutable` | `DateTimeInterface` → `string` | -| `timestamp` | `TimestampCast` | `int`/`string` → `DateTimeImmutable` | `DateTimeInterface` → `int` | - -Notes: - -- **`bool` vs `int-bool`** — `bool` only transforms on read; if your column - stores `0/1` and you want the **write** to emit an int, use `int-bool`. -- **`json[array]`** decodes objects as associative arrays; plain `json` yields a - `stdClass`. -- **`datetime` format param** — `''`→`Y-m-d H:i:s`, `ms`→`…H:i:s.v`, - `us`→`…H:i:s.u`, or any literal PHP date format (e.g. `datetime[Y-m-d]`). - ---- - -## Custom casts - -Implement `CastInterface` (or extend `BaseCast` to inherit identity behaviour for -the direction you don't need) and register it by type key: - -```php -use Project\Support\Casting\CastInterface; - -final class MoneyCast implements CastInterface -{ - public static function get(mixed $value, array $params = [], ?object $helper = null): Money - { - return Money::ofCents((int) $value); // DB int cents → Money VO - } - - public static function set(mixed $value, array $params = [], ?object $helper = null): int - { - return $value instanceof Money ? $value->cents() : (int) $value; - } -} - -$caster = new DataCaster( - castHandlers: ['money' => MoneyCast::class], - types: ['total' => 'money'], -); - -$caster->castAs(1999, 'total'); // Money(19.99) -$caster->castAs(Money::of(19.99), 'total', 'set'); // 1999 -``` - -Custom handlers are **merged over** the defaults, so you can also override a -built-in type key with your own implementation. - ---- - -## `DataConverter` — the hydrator - -Maps a whole row, both directions, running every field through a pooled -`DataCaster`. This is what a Repository uses instead of the old -`Entity::find()/save()`. - -```php -use Project\Support\Hydration\DataConverter; - -new DataConverter( - array $types, // [column => typeString] - array $castHandlers = [], // custom casts - ?object $helper = null, - Closure|string $reconstructor = 'reconstitute', // static factory name OR closure - Closure|string $extractor = 'toRawArray', // method name OR closure -); -``` - -| Method | Returns | Notes | -| --- | --- | --- | -| `fromDataSource(array $row)` | `array` | Row → PHP-typed array (`get` on each known field) | -| `toDataSource(array $php)` | `array` | PHP array → DB-typed array (`set` on each known field) | -| `reconstruct(string $class, array $row)` | `object` | Hydrate an object from a raw row | -| `extract(object $obj)` | `array` | Object → DB-typed column array | - -Reconstruction resolves in this order: - -1. a **`Closure`** reconstructor → `$closure($phpData)` -2. a **static factory** named by the string (default `reconstitute`) → - `Class::reconstitute($phpData)` -3. otherwise throws `RuntimeException` (no reflection back-door). - -Extraction resolves: a **`Closure`** → a **method name** (default `toRawArray`) → -fallback to public state via `(array) $object` (private/protected keys dropped). - ---- - -## Cookbook — exhaustive examples - -### 1. Standalone caster, both directions - -```php -$c = new DataCaster(types: ['n' => 'int', 'on' => 'bool'], strict: false); -$c->castAs('7', 'n'); // 7 -$c->castAs('true', 'on'); // true -$c->castAs(7, 'n', 'set'); // 7 (IntegerCast set is identity) -``` - -### 2. Every built-in type - -```php -$c = new DataCaster(strict: false, types: [ - 'i' => 'int', 'f' => 'float', 's' => 'string', 'b' => 'bool', - 'ib' => 'int-bool','csv'=> 'csv', 'arr'=> 'array', 'j' => 'json', - 'ja' => 'json[array]', 'o' => 'object', 'dt' => 'datetime', 'ts' => 'timestamp', -]); - -$c->castAs('5', 'i'); // 5 -$c->castAs('9.95', 'f'); // 9.95 -$c->castAs(123, 's'); // '123' -$c->castAs('1', 'b'); // true -$c->castAs(true, 'ib', 'set'); // 1 -$c->castAs('a,b,c', 'csv'); // ['a','b','c'] -$c->castAs(['x' => 1], 'arr', 'set'); // 'a:1:{s:1:"x";i:1;}' (serialized) -$c->castAs('{"k":1}', 'j'); // stdClass { k: 1 } -$c->castAs('{"k":1}', 'ja'); // ['k' => 1] -$c->castAs('2024-01-02 03:04:05','dt');// DateTimeImmutable -$c->castAs('1700000000', 'ts'); // DateTimeImmutable @1700000000 -``` - -### 3. Nullable vs. strict - -```php -$strict = new DataCaster(types: ['x' => 'int']); // strict: true (default) -$strict->castAs(null, 'x'); // ❌ InvalidArgumentException (not nullable) - -$nullable = new DataCaster(types: ['x' => '?int']); -$nullable->castAs(null, 'x'); // null (passes through) - -$lenient = new DataCaster(types: ['x' => 'int'], strict: false); -// strict:false stops the null guard from throwing, but the handler may still -// reject null — always prefer the explicit `?int` for nullable columns. -``` - -### 4. Datetime formats - -```php -$c = new DataCaster(strict: false, types: [ - 'a' => 'datetime', // Y-m-d H:i:s - 'b' => 'datetime[ms]', // Y-m-d H:i:s.v - 'c' => 'datetime[Y-m-d]', // literal format -]); -$dt = new DateTimeImmutable('2024-12-25 10:30:00'); -$c->castAs($dt, 'a', 'set'); // '2024-12-25 10:30:00' -$c->castAs($dt, 'c', 'set'); // '2024-12-25' -$c->castAs('2024-12-25', 'c'); // DateTimeImmutable (parsed with that format) -``` - -### 5. Custom value-object cast - -```php -final class MoneyCast implements \Project\Support\Casting\CastInterface { - public static function get(mixed $v, array $p = [], ?object $h = null): Money { return Money::ofCents((int) $v); } - public static function set(mixed $v, array $p = [], ?object $h = null): int { return $v instanceof Money ? $v->cents() : (int) $v; } -} -$c = new DataCaster(castHandlers: ['money' => MoneyCast::class], types: ['total' => 'money']); -$c->castAs(2500, 'total'); // Money(25.00) -$c->castAs(Money::of(25), 'total', 'set'); // 2500 -``` - -### 6. Passing a helper to casts - -```php -// The 3rd ctor arg is forwarded to every cast as $helper — e.g. a connection, -// a clock, or any context object a custom cast needs. -$c = new DataCaster( - castHandlers: ['tzdate' => TimezoneDateCast::class], - types: ['at' => 'tzdate'], - helper: $clock, // TimezoneDateCast::get($v, $p, $clock) -); -``` - -### 7. Reusing a caster with `setTypes` - -```php -$c = new DataCaster(strict: false); -$c->setTypes(['a' => 'int'])->castAs('1', 'a'); // 1 -$c->setTypes(['a' => 'bool'])->castAs('1', 'a'); // true (parse cache reset) -``` - -### 8. Hydrating a single row - -```php -use Project\Support\Hydration\DataConverter; - -$conv = new DataConverter( - types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], -); -$invoice = $conv->reconstruct(Invoice::class, [ - 'id' => '7', 'paid' => '1', 'meta' => '{"k":1}', -]); -// Invoice::reconstitute(['id'=>7, 'paid'=>true, 'meta'=>['k'=>1]]) -``` - -### 9. Hydrating with a closure (no static factory) - -```php -$conv = new DataConverter( - types: ['id' => 'int'], - reconstructor: fn(array $d) => new Dto($d['id']), - extractor: fn(Dto $o) => ['id' => $o->id], -); -$dto = $conv->reconstruct(Dto::class, ['id' => '3']); // Dto(3) -$row = $conv->extract($dto); // ['id' => 3] -``` - -### 10. Extracting an object to a DB row - -```php -$conv = new DataConverter( - types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], - extractor: 'toRawArray', -); -$columns = $conv->extract($invoice); // ['id'=>3, 'paid'=>false, 'meta'=>'{"a":2}'] -$db->upsert('invoices', $columns, ['id']); -``` - -### 11. Mapping arrays directly (no objects) - -```php -$conv = new DataConverter(types: ['id' => 'int', 'active' => 'bool']); -$php = $conv->fromDataSource(['id' => '9', 'active' => '0', 'name' => 'ada']); -// ['id' => 9, 'active' => false, 'name' => 'ada'] (untyped keys pass through) -$store = $conv->toDataSource(['id' => 9, 'active' => false]); -// ['id' => 9, 'active' => false] -``` - -### 12. Full Repository CRUD (the GDA replacement for `Entity::find/save`) - -```php -use Project\Support\Hydration\DataConverter; - -final class InvoiceRepository -{ - private DataConverter $converter; - - public function __construct( - private readonly DatabasePort $db, - private readonly Identity $identity, - ) { - $this->converter = new DataConverter( - types: ['id' => 'int', 'paid' => 'bool', 'meta' => 'json[array]'], - reconstructor: 'reconstitute', // public static Invoice::reconstitute(array): self - extractor: 'toRawArray', // public Invoice::toRawArray(): array - ); - } - - public function find(string $id): Invoice - { - $row = $this->db->queryOne( - 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t', - ['id' => $id, 't' => $this->identity->tenantId], - ) ?? throw new RepositoryException("Invoice [{$id}] not found", layer: 'repository.invoice'); - - return $this->converter->reconstruct(Invoice::class, $row); - } - - /** @return Invoice[] */ - public function all(): array - { - $rows = $this->db->query( - 'SELECT * FROM invoices WHERE tenant_id = :t', - ['t' => $this->identity->tenantId], - ); - - return array_map(fn($r) => $this->converter->reconstruct(Invoice::class, $r), $rows); - } - - public function save(Invoice $invoice): void - { - $this->db->upsert('invoices', $this->converter->extract($invoice), ['id']); - } -} -``` - -The Domain `Invoice` stays pure: a `final` class (or one extending the -[`Entity` base](../Entity/README.md)) with `static reconstitute(array)`, -`toRawArray()`, state-transition methods that record domain events, and zero -infrastructure imports. - -### 13. Overriding a built-in type - -```php -// Replace the default 'json' behaviour project-wide: -$conv = new DataConverter( - types: ['payload' => 'json'], - castHandlers: ['json' => StrictJsonCast::class], // your own implementation wins -); -``` - ---- - -## Design notes & caveats - -- **Casts are static & stateless** — pure transforms, safe to share and call - concurrently (OpenSwoole-safe; no per-request state). -- **`DataConverter` pools `DataCaster` instances** keyed by a hash of - `types + castHandlers`, so many converters with the same shape share one - caster (memory win). The pool holds immutable config, not request data. -- **Prefer `?type` over `strict: false`** for nullable columns — it is explicit - and survives a handler that rejects `null`. -- **`array` cast uses PHP `serialize()`** (unserialize is restricted to - `allowed_classes => false`). Use `json`/`json[array]` if you need portable, - language-agnostic storage. -- **No reflection back-door** — `reconstruct()` requires a static factory or a - closure; it will not write private properties behind the entity's back. Give - your Domain entity a `reconstitute()`/`toRawArray()` (the - [`Entity` base](../Entity/README.md) provides both). -- This layer never does I/O. The DB call belongs to the Repository; the cast - layer only shapes values. diff --git a/projects/Support/Entity/README.md b/projects/Support/Entity/README.md deleted file mode 100644 index 667f240..0000000 --- a/projects/Support/Entity/README.md +++ /dev/null @@ -1,821 +0,0 @@ -# Entity support (`Project\Support\Entity\Entity`) - -An enterprise-grade, **GDA-safe** base class for every domain entity — the -refactored core of the legacy `__DEV__/Entity` Active Record, with all the -persistence/ORM machinery stripped out and a hardened, secure feature set added. - -- **Namespace:** `Project\Support\Entity` -- **Autoload:** `Project\` → `projects/` (PSR-4, already wired in `composer.json`) -- **Pairs with:** [`Project\Support\Casting\DataCaster`](../Casting/README.md) (the `$casts` engine) - and `Project\Support\Hydration\DataConverter` (row ⇄ object mapping) - -It performs **no I/O**, reads **no globals** (`app()`/`kernel()`/`config()`), and -imports only the sibling casting utility — so it is safe to extend from a -plugin's `Domain/` layer without violating the Five Access Rules. - ---- - -## Table of contents - -1. [Why it was decomposed, not relocated](#why-it-was-decomposed-not-relocated) -2. [What it keeps vs. removes](#what-it-keeps-vs-removes) -3. [Quick start](#quick-start) -4. [Configuration properties](#configuration-properties) -5. [Full API reference](#full-api-reference) -6. [Type casting (`$casts`)](#type-casting-casts) -7. [Accessors & mutators](#accessors--mutators) -8. [Security model](#security-model) -9. [Serialization](#serialization) -10. [Change tracking](#change-tracking) -11. [Domain events](#domain-events) -12. [Immutability sealing](#immutability-sealing) -13. [Use in the GDA layers](#use-in-the-gda-layers) -14. [Cookbook — exhaustive examples](#cookbook--exhaustive-examples) -15. [Design notes & caveats](#design-notes--caveats) - ---- - -## Why it was decomposed, not relocated - -`__DEV__/Entity/Entity.php` is a CodeIgniter/Eloquent-style **fat Active Record**: -magic `__get/__set`, mutators/accessors, `save()/delete()/restore()`, -`performInsert/Update`, `getRepo_()`, WP-style meta tables and change tracking — -all in one base. GDA explicitly forbids this (no ORM/AR in the Domain layer, no -entity importing infrastructure, no entity calling its own repository). - -So the single class was split across the layers it conflated. This base keeps -only the **pure, infrastructure-free entity mechanics**; persistence and request -validation move to their proper homes. - -## What it keeps vs. removes - -| Kept (pure entity mechanics) | Removed (moved to its GDA home) | -| --- | --- | -| attribute bag + change tracking | `save()` / `delete()` / `restore()` → **Repository** (`DatabasePort`) | -| type casting via `DataCaster` (`$casts`) | `performInsert/Update`, meta tables, `getRepo_()` → **Repository** | -| `get{X}Attribute` / `set{X}Attribute` hooks | magic `__get` DB fallback → gone (entities never query) | -| domain-event buffer | `app()` / `kernel()` global lookups → gone | -| `reconstitute()` / `toRawArray()` (Hydrator seam) | — | -| mass-assignment guard (kept as a defense-in-depth safety net) | (primary validation still belongs in the DTO at the controller edge) | - ---- - -## Quick start - -```php -use Project\Support\Entity\Entity; - -final class User extends Entity -{ - protected string $primaryKey = 'id'; - - protected array $casts = [ - 'id' => 'int', - 'active' => 'bool', - 'roles' => '?json[array]', - 'createdAt' => 'datetime', - ]; - - protected array $fillable = ['name', 'email', 'active', 'roles']; - protected array $hidden = ['password']; // never serialized / dumped - protected array $appends = ['display']; // computed, added to output - protected array $dates = ['createdAt']; - - // Named constructor — records a creation event - public static function register(string $name, string $email): self - { - $u = (new self())->fill(['name' => $name, 'email' => $email, 'active' => true]); - $u->recordEvent(new UserRegistered($email)); - return $u; - } - - // Computed accessor surfaced via $appends - public function getDisplayAttribute(): string - { - return strtoupper($this->getString('name')); - } -} -``` - -```php -$user = User::register('ada', 'ada@example.com'); -$user->getBool('active'); // true -$user->toArray(); // ['name'=>'ada', ..., 'display'=>'ADA'] (no 'password') -foreach ($user->releaseEvents() as $event) { /* hand to the collector */ } -``` - ---- - -## Configuration properties - -Override these `protected` properties in your subclass: - -| Property | Type | Default | Purpose | -| --- | --- | --- | --- | -| `$primaryKey` | `string` | `'id'` | Key field used by `getKey()`/`exists()`/`is()` | -| `$casts` | `array` | `[]` | Field → cast type (see [Type casting](#type-casting-casts)) | -| `$customCasters` | `array` | `[]` | Extra cast handlers `[type => CastInterface]` | -| `$fillable` | `list` | `[]` | Mass-assignment whitelist | -| `$guarded` | `list` | `['*']` | Mass-assignment blacklist (default: deny all) | -| `$hidden` | `list` | `[]` | Excluded from array/JSON **and** redacted in dumps | -| `$visible` | `list` | `[]` | If set, ONLY these appear in array/JSON | -| `$appends` | `list` | `[]` | Computed accessor names added to output | -| `$dates` | `list` | `[]` | Fields serialized via `$dateFormat` | -| `$dateFormat` | `string` | `'Y-m-d H:i:s'` | Date serialization format | - ---- - -## Full API reference - -### Construction / lifecycle - -| Method | Returns | Notes | -| --- | --- | --- | -| `new static(?array $attributes = null)` | — | Raw hydration; **bypasses** guards; syncs original | -| `static::make()` | `static` | Blank instance | -| `static::reconstitute(array $row)` | `static` | Hydrate from a DB row; **records no events** | -| `replicate(array $except = [])` | `static` | Copy **without** the primary key (and `$except`) | -| `__clone()` | — | Resets change-tracking, events and seal | - -### Mass assignment - -| Method | Returns | Notes | -| --- | --- | --- | -| `fill(array $data)` | `static` | Writes only fillable keys (safe) | -| `forceFill(array $data)` | `static` | Bypasses guards — trusted data only | -| `isFillable(string $key)` | `bool` | Guard evaluation | - -### Attribute access - -| Method | Returns | -| --- | --- | -| `getAttribute(string $key)` | cast + accessor-applied value | -| `setAttribute(string $key, $value)` | `static` (mutator + cast applied) | -| `getRawAttribute(string $key)` | uncast stored value | -| `hasAttribute(string $key)` | `bool` | -| `only(array $keys)` / `except(array $keys)` | `array` | - -### Typed, null-safe getters - -| Method | Returns | -| --- | --- | -| `getString($key, $default='')` | `string` | -| `getInt($key, $default=0)` | `int` | -| `getFloat($key, $default=0.0)` | `float` | -| `getBool($key, $default=false)` | `bool` | -| `getArray($key, $default=[])` | `array` | -| `getDate($key)` | `?DateTimeImmutable` | - -### Serialization methods - -| Method | Returns | -| --- | --- | -| `toArray(bool $onlyChanged=false)` | visibility-filtered, cast, appends + dates | -| `toRawArray(bool $onlyChanged=false)` | raw DataSource-shaped attributes | -| `jsonSerialize()` | `array` (= `toArray()`) | -| `toJson(int $flags=0)` | `string` (throws on encode error) | -| `__toString()` | JSON | -| `makeHidden($keys)` / `makeVisible($keys)` | `static` | - -### Change-tracking methods - -| Method | Returns | -| --- | --- | -| `syncOriginal()` | `static` — snapshot current state | -| `isDirty(...$keys)` | `bool` | -| `isClean(...$keys)` | `bool` | -| `wasChanged(...$keys)` | `bool` (alias of `isDirty`) | -| `getChanges()` / `getDirty()` | `array` of changed fields | -| `getOriginal(?string $key=null, $default=null)` | snapshot value(s) | - -### Identity helpers - -| Method | Returns | -| --- | --- | -| `getKey()` | primary key value | -| `getKeyName()` | key field name | -| `exists()` | `bool` (non-empty key) | -| `is(?Entity $other)` / `isNot(?Entity $other)` | `bool` (same class + key) | - -### Domain-event methods - -| Method | Returns | -| --- | --- | -| `recordEvent(object $event)` | `void` (`protected`) | -| `hasEvents()` | `bool` | -| `releaseEvents()` | `list` (returns **and clears**) | - -### Immutability - -| Method | Returns | -| --- | --- | -| `seal()` | `static` — lock the bag | -| `isSealed()` | `bool` | - -### Interfaces implemented - -`JsonSerializable`, `ArrayAccess` (`$entity['field']`), `Stringable`. - ---- - -## Type casting (`$casts`) - -Casting is bidirectional and runs through `DataCaster`: - -- **read** (`getAttribute`/`toArray`) → `get` direction (DataSource → PHP) -- **write** (`setAttribute`) → `set` direction (PHP → DataSource) - -```php -protected array $casts = [ - 'id' => 'int', - 'price' => 'float', - 'active' => 'bool', - 'flags' => 'int-bool', // bool in PHP, 0/1 in the column - 'tags' => 'csv', - 'meta' => '?json[array]', // ? = nullable, [array] = decode assoc - 'opened' => 'datetime', // datetime[ms] / datetime[us] / datetime[Y-m-d] -]; -``` - -Built-in types: `int|integer`, `float|double`, `string`, `bool|boolean`, -`int-bool`, `csv`, `array`, `json`, `object`, `datetime`, `timestamp`. -Register custom ones via `$customCasters` (must implement -`Project\Support\Casting\CastInterface`). Full grammar: -[Casting README](../Casting/README.md). - -> `'bool'` casts only on **read**; use `'int-bool'` when the column stores `0/1` -> and you want `toRawArray()` to emit an int. - ---- - -## Accessors & mutators - -Define `get{Studly}Attribute($value)` / `set{Studly}Attribute($value)` to hook a -single field. Studly conversion handles `snake_case`, `kebab-case` and spaces. - -```php -public function getNameAttribute($v): string { return ucfirst((string) $v); } -public function setEmailAttribute($v): string { return strtolower(trim((string) $v)); } -``` - -Accessors run **after** casting on read; mutators run **before** casting on write. -Method existence is memoized per class for performance. - ---- - -## Security model - -**Mass assignment is denied by default.** - -```php -protected array $guarded = ['*']; // nothing mass-assignable… -protected array $fillable = ['name', 'email']; // …except these -``` - -```php -$user->fill($request->all()); // 'id', 'is_admin', 'password' silently dropped -$user->forceFill($trusted); // bypass — ONLY for internal, trusted data -``` - -This is **defense in depth**: the DTO at the controller edge is still the primary -validator; the entity guard is the second line so over-posting can never reach -the attribute bag. - -**Secrets never leak into logs.** `__debugInfo()` redacts every `$hidden` field -as `********`, so `var_dump($entity)`, stack traces and error dumps stay safe: - -```php -protected array $hidden = ['password', 'api_token']; -// var_dump($user) → ['password' => '********', 'api_token' => '********', ...] -``` - -**Read-only snapshots.** `seal()` makes the bag immutable — any -`set`/`__set`/`offsetSet`/`unset` throws `LogicException`. Use for cached -projections shared within a request so accidental writes are impossible. - ---- - -## Serialization - -`toArray()` / `jsonSerialize()` / `toJson()` apply, in order: - -1. **Visibility** — drop `$hidden`; if `$visible` is set, keep only those. -2. **Casting** — each value via its `$casts` entry. -3. **Date formatting** — `$dates` fields via `$dateFormat`; any - `DateTimeInterface` value is formatted; nested `JsonSerializable` is unwrapped. -4. **Appends** — each `$appends` accessor (subject to visibility). - -`toRawArray()` returns the **raw** stored attributes (DataSource shape) for -persistence — let the `DataConverter` apply row-level casts if you want a fully -typed raw array. - ---- - -## Change tracking - -```php -$user->syncOriginal(); // baseline (Repository calls this after load/save) -$user->name = 'grace'; -$user->isDirty(); // true -$user->isDirty('email'); // false -$user->wasChanged('name'); // true -$user->getDirty(); // ['name' => 'grace'] -$user->getOriginal('name'); // 'ada' -``` - -A Repository typically persists only `toRawArray(onlyChanged: true)` and calls -`syncOriginal()` after a successful write. - ---- - -## Domain events - -Entities **record** events during state changes; the **Service** flushes them -inside the transaction/commit pattern — the entity never dispatches. - -```php -public function deactivate(): void -{ - if (! $this->getBool('active')) { - throw new \DomainException('User already inactive'); - } - $this->active = false; - $this->recordEvent(new UserDeactivated($this->getKey())); -} -``` - -```php -// In the Service: -$user->deactivate(); -foreach ($user->releaseEvents() as $event) { - $this->collector->collect($event); // buffered in-tx, discarded on rollback -} -$this->repository->save($user); -``` - -`reconstitute()` (hydration) records **no** events. - ---- - -## Immutability sealing - -```php -$snapshot = User::reconstitute($row)->seal(); -$snapshot->name; // ✅ read freely -$snapshot->name = 'x'; // ❌ throws LogicException -$snapshot->isSealed(); // true -$copy = clone $snapshot; // clone is unsealed + tracking reset -``` - ---- - -## Use in the GDA layers - -```php -// ── Domain entity ── extends this base, no infrastructure imports -final class Invoice extends Entity { /* $casts, named ctors, transitions */ } - -// ── Service ── transaction + event pattern -$invoice->pay(); -foreach ($invoice->releaseEvents() as $e) { - $this->collector->collect($e); -} -$this->repository->save($invoice); - -// ── Repository ── the ONLY place that touches the DB (DatabasePort) -public function find(string $id): Invoice -{ - $row = $this->db->queryOne( - 'SELECT * FROM invoices WHERE id = :id AND tenant_id = :t', - ['id' => $id, 't' => $this->identity->tenantId] - ) ?? throw new RepositoryException("Invoice [{$id}] not found", layer: 'repository.invoice'); - - return Invoice::reconstitute($row); // or via DataConverter to apply casts -} - -public function save(Invoice $invoice): void -{ - $this->db->upsert('invoices', $invoice->toRawArray(onlyChanged: true), ['id']); - $invoice->syncOriginal(); -} -``` - -For automatic row-level casting through the hydrator, see the `DataConverter` -example in the [Casting README](../Casting/README.md). - ---- - -## Cookbook — exhaustive examples - -A copy-pasteable reference for every feature. Each block is self-contained. - -### 1. Defining an entity - -```php -use Project\Support\Entity\Entity; - -final class Article extends Entity -{ - protected string $primaryKey = 'id'; - - protected array $casts = [ - 'id' => 'int', - 'published' => 'bool', - 'views' => 'int', - 'rating' => 'float', - 'tags' => 'csv', - 'meta' => '?json[array]', - 'publishedAt' => '?datetime', - ]; - - protected array $fillable = ['title', 'body', 'tags', 'published']; - protected array $hidden = ['authorEmail']; - protected array $appends = ['excerpt']; - protected array $dates = ['publishedAt']; - - public function getExcerptAttribute(): string - { - return mb_substr($this->getString('body'), 0, 80); - } -} -``` - -### 2. Every cast type, round-tripped - -```php -$e = new class extends Entity { - protected array $casts = [ - 'n' => 'int', - 'amt' => 'float', - 's' => 'string', - 'b' => 'bool', - 'ib' => 'int-bool', // bool in PHP, 0/1 in DB - 'csv' => 'csv', - 'arr' => 'array', // PHP serialize() in DB - 'j' => 'json', - 'ja' => 'json[array]', // decode as assoc array - 'o' => 'object', - 'dt' => 'datetime', - 'ts' => 'timestamp', - ]; -}; - -$e->n = '42'; $e->n; // 42 (int) -$e->amt = '9.95'; $e->amt; // 9.95 (float) -$e->b = '1'; $e->b; // true (bool) -$e->ib = true; $e->toRawArray()['ib']; // 1 (int in DB shape) -$e->csv = ['a','b']; $e->csv; // ['a','b'] (array on read) -$e->ja = '{"k":1}'; $e->ja; // ['k' => 1] -$e->dt = '2024-01-02 03:04:05'; -$e->dt; // DateTimeImmutable -``` - -### 3. Custom cast (value object) - -```php -use Project\Support\Casting\CastInterface; - -final class MoneyCast implements CastInterface -{ - public static function get(mixed $v, array $p = [], ?object $h = null): Money - { - return Money::ofCents((int) $v); // DB int cents -> Money VO - } - public static function set(mixed $v, array $p = [], ?object $h = null): int - { - return $v instanceof Money ? $v->cents() : (int) $v; - } -} - -final class Order extends Entity -{ - protected array $customCasters = ['money' => MoneyCast::class]; - protected array $casts = ['total' => 'money']; -} - -$order = new Order(); -$order->total = Money::of(19.99); // stored as 1999 (cents) -$order->total; // Money VO again -$order->toRawArray()['total']; // 1999 -``` - -### 4. Accessors & mutators - -```php -final class Person extends Entity -{ - protected array $casts = ['name' => 'string']; - - // read transform (runs AFTER cast) - public function getNameAttribute($v): string { return ucwords((string) $v); } - - // write transform (runs BEFORE cast) - public function setEmailAttribute($v): string { return strtolower(trim((string) $v)); } - - // computed, exposed via $appends - protected array $appends = ['initials']; - public function getInitialsAttribute(): string - { - return implode('', array_map(fn($p) => $p[0] ?? '', explode(' ', $this->getString('name')))); - } -} - -$p = new Person(); -$p->name = 'ada lovelace'; $p->name; // 'Ada Lovelace' -$p->email = ' A@B.C '; $p->getRawAttribute('email'); // 'a@b.c' -$p->toArray()['initials']; // 'AL' -``` - -### 5. Mass assignment — safe vs. forced - -```php -final class Account extends Entity -{ - protected array $fillable = ['name', 'email']; // only these are mass-assignable - // $guarded defaults to ['*'] => everything else blocked -} - -$a = (new Account())->fill([ - 'name' => 'ada', - 'email' => 'a@b.c', - 'is_admin' => true, // ← silently dropped (not fillable) - 'id' => 999, // ← silently dropped -]); -$a->hasAttribute('is_admin'); // false - -// trusted, internal data only: -$a->forceFill(['id' => 7, 'is_admin' => true]); -$a->isFillable('email'); // true -$a->isFillable('is_admin'); // false -``` - -Whitelist instead of default-deny: - -```php -final class Tag extends Entity -{ - protected array $guarded = ['id']; // everything fillable EXCEPT id -} -``` - -### 6. Typed, null-safe getters - -```php -$e->getString('name', 'anon'); // string, default if null -$e->getInt('age'); // 0 if missing/non-numeric -$e->getFloat('rate'); // 0.0 default -$e->getBool('active'); // false default; understands "1"/"true"/"on"/"yes" -$e->getArray('roles'); // [] default; decodes a JSON string too -$e->getDate('createdAt'); // ?DateTimeImmutable (parses int/string) -``` - -### 7. Visibility — static and runtime - -```php -final class Secretish extends Entity -{ - protected array $hidden = ['password']; -} - -$s = Secretish::reconstitute(['id' => 1, 'password' => 'x', 'name' => 'ada']); -$s->toArray(); // ['id'=>1, 'name'=>'ada'] (no password) - -$s->makeVisible('password'); // expose at runtime -array_key_exists('password', $s->toArray()); // true - -$s->makeHidden(['name']); // hide more at runtime -$s->toArray(); // ['id'=>1, 'password'=>'x'] - -// whitelist mode — ONLY listed fields ever appear: -final class Slim extends Entity { protected array $visible = ['id', 'name']; } -``` - -### 8. Serialization surfaces - -```php -$e->toArray(); // cast + visibility + dates + appends -$e->toArray(onlyChanged: true);// only changed fields -$e->toRawArray(); // raw DB-shaped attributes (for persistence) -$e->jsonSerialize(); // == toArray() -$e->toJson(JSON_PRETTY_PRINT); // string (throws on encode error) -(string) $e; // JSON via Stringable -json_encode($e); // uses JsonSerializable automatically - -// dates honour $dates + $dateFormat -final class Event extends Entity { - protected array $dates = ['startsAt']; - protected string $dateFormat = 'Y-m-d'; -} -$ev = Event::reconstitute(['startsAt' => '2024-12-25 10:00:00']); -$ev->toArray()['startsAt']; // '2024-12-25' -``` - -### 9. ArrayAccess - -```php -$e['title'] = 'Hello'; // setAttribute (mutator + cast) -$e['title']; // getAttribute (cast + accessor) -isset($e['title']); // accessor value !== null -unset($e['title']); // removes from the bag -``` - -### 10. Change tracking & dirty-only persistence - -```php -$e = Article::reconstitute(['id' => 1, 'title' => 'A', 'views' => 10]); -$e->isDirty(); // false (just hydrated) - -$e->title = 'B'; -$e->views = 11; -$e->isDirty(); // true -$e->isDirty('title'); // true -$e->isClean('id'); // true -$e->wasChanged('views'); // true -$e->getDirty(); // ['title'=>'B', 'views'=>11] -$e->getChanges(); // (alias of getDirty) -$e->getOriginal('title'); // 'A' -$e->getOriginal(); // full original snapshot - -// persist only what changed, then re-baseline -$db->upsert('articles', $e->toRawArray(onlyChanged: true), ['id']); -$e->syncOriginal(); -$e->isDirty(); // false again -``` - -### 11. Domain events (Service pattern) - -```php -final class Subscription extends Entity -{ - public static function start(string $plan): self - { - $s = (new self())->forceFill(['plan' => $plan, 'status' => 'active']); - $s->recordEvent(new SubscriptionStarted($plan)); - return $s; - } - - public function cancel(): void - { - if ($this->getString('status') === 'cancelled') { - throw new \DomainException('Already cancelled'); - } - $this->status = 'cancelled'; - $this->recordEvent(new SubscriptionCancelled($this->getKey())); - } -} - -// In the Application Service — flush inside the transaction: -$sub->cancel(); -$this->collector->beginCollection(); -$this->transaction->begin(); -try { - $this->repository->save($sub); - foreach ($sub->releaseEvents() as $event) { // returns AND clears - $this->collector->collect($event); - } - $this->transaction->commit(); -} catch (\Throwable $e) { - $this->transaction->rollback(); - $this->collector->discard(); - throw $e; -} - -$sub->hasEvents(); // false — buffer drained -``` - -### 12. Immutability sealing (read-only snapshots) - -```php -$snapshot = Article::reconstitute($row)->seal(); -$snapshot->title; // ✅ reads fine -try { - $snapshot->title = 'x'; // ❌ throws LogicException -} catch (\LogicException $e) { /* sealed */ } - -$snapshot->isSealed(); // true -$editable = clone $snapshot; // clone is UNSEALED + tracking reset -$editable->isSealed(); // false -``` - -### 13. Replication & cloning - -```php -$tpl = Article::reconstitute(['id' => 5, 'title' => 'Template', 'views' => 99]); - -$copy = $tpl->replicate(); // no primary key -$copy->getRawAttribute('id'); // null → save() inserts a new row -$copy->getString('title'); // 'Template' - -$copy2 = $tpl->replicate(except: ['views']); // also drop views - -$clone = clone $tpl; // keeps attributes; resets original/events/seal -$clone->getOriginal(); // [] -``` - -### 14. Identity & comparison - -```php -$a = Article::reconstitute(['id' => 1]); -$b = Article::reconstitute(['id' => 1]); -$c = Article::reconstitute(['id' => 2]); -$new = new Article(); - -$a->is($b); // true (same class + same non-empty key) -$a->isNot($c); // true -$a->is($new); // false (new has no key) -$new->exists(); // false -$a->getKey(); // 1 -$a->getKeyName(); // 'id' -``` - -### 15. `only()` / `except()` - -```php -$e->only(['id', 'title']); // ['id'=>.., 'title'=>..] (cast values) -$e->except(['authorEmail']); // toArray() minus those keys -``` - -### 16. Full Repository CRUD with the Hydrator - -```php -use Project\Support\Hydration\DataConverter; - -final class ArticleRepository -{ - private DataConverter $converter; - - public function __construct( - private readonly DatabasePort $db, - private readonly Identity $identity, - ) { - $this->converter = new DataConverter( - types: ['id' => 'int', 'published' => 'bool', 'meta' => 'json[array]'], - reconstructor: 'reconstitute', // Article::reconstitute(array) - extractor: 'toRawArray', // Article::toRawArray() - ); - } - - public function find(string $id): Article - { - $row = $this->db->queryOne( - 'SELECT * FROM articles WHERE id = :id AND tenant_id = :t', - ['id' => $id, 't' => $this->identity->tenantId], - ) ?? throw new RepositoryException("Article [{$id}] not found", layer: 'repository.article'); - - return $this->converter->reconstruct(Article::class, $row); // casts applied - } - - /** @return Article[] */ - public function all(): array - { - $rows = $this->db->query('SELECT * FROM articles WHERE tenant_id = :t', - ['t' => $this->identity->tenantId]); - - return array_map(fn($r) => $this->converter->reconstruct(Article::class, $r), $rows); - } - - public function save(Article $a): void - { - $this->db->upsert('articles', $this->converter->extract($a), ['id']); - $a->syncOriginal(); - } -} -``` - -### 17. Serializing a collection - -```php -$articles = $repo->all(); -$payload = array_map(static fn(Article $a) => $a->toArray(), $articles); -$json = json_encode($articles); // each element uses JsonSerializable -``` - -### 18. Safe logging (secret redaction) - -```php -final class Credentials extends Entity { protected array $hidden = ['secret', 'token']; } - -$c = Credentials::reconstitute(['id' => 1, 'secret' => 'sk_live_x', 'token' => 'abc']); -var_dump($c); -// ['id'=>1, 'secret'=>'********', 'token'=>'********'] ← __debugInfo() redaction -log_debug(print_r($c, true)); // also redacted -``` - ---- - -## Design notes & caveats - -- This is a **convenience** base with a public attribute bag. The strict GDA gold - standard is still a `final` entity with a private constructor and fully - encapsulated state (private typed properties, no bag). Extend this base when - the flexible, WordPress-style attribute bag genuinely earns its keep - (heterogeneous/meta-driven records); prefer a hand-written `final` entity for - small, well-defined aggregates. -- `static::$methodCache` memoizes `method_exists` results. It caches **immutable - facts** (does class X define method Y), not request data, so it is safe under - OpenSwoole and does not leak between requests. -- `exists()` treats `null`, `''`, `0`, `'0'` as "no key". -- `offsetExists()`/`__isset()` use the **accessor** value (so a `null` cast result - reads as not-set); use `hasAttribute()` for a pure key-presence check. -- The base never validates business rules — invariants belong in the entity's own - transition methods (throwing `\DomainException`) and in DTOs. diff --git a/projects/Support/README.md b/projects/Support/README.md deleted file mode 100644 index d27c268..0000000 --- a/projects/Support/README.md +++ /dev/null @@ -1,235 +0,0 @@ -# `Project\Support` — Project-Layer Support Library - -> Namespace `Project\Support\` → `projects/Support/` (composer psr-4 `"Project\\": "projects/"`). - -Reusable, **DI-free** helpers that belong to the PROJECT layer. Every class here: - -- performs **no I/O** (the SEO sitemap writers are the one deliberate exception — - they write files you hand them a path for), -- reads **no globals** (`config()`, `kernel()`, `app()` never appear), -- imports **nothing** outside its own namespace + PHP built-ins. - -That is what makes them safe to call from a plugin's `Domain/`, `Application/` -or `Infrastructure/` layer without breaking the Five Access Rules: they are -value-level utilities, not services. They are plain autoloaded classes — nothing -here is bound in a container and nothing needs a module load. - ---- - -## Map - -| Path | Class(es) | Role | Doc | -|---|---|---|---| -| `Arr.php` | `Arr` | Static array helpers (dot access, pluck, flatten) | this file | -| `Str.php` | `Str` | Static string helpers (case conversion, slug, random) | this file | -| `Collection.php` | `Collection` | Fluent, chainable array wrapper | this file | -| `Resource.php` | `Resource` | API transformer base — one class per output shape | this file | -| `ResourceCollection.php` | `ResourceCollection` | List of items transformed by a `Resource` | this file | -| `Casting/` | `DataCaster`, `TypeParser`, `CastInterface`, 11 casts | Cast ONE field value DB↔PHP | [`Casting/README.md`](Casting/README.md) | -| `Hydration/` | `DataConverter` | Map a whole DB row ⇄ object | this file + [`Casting/README.md`](Casting/README.md) | -| `Entity/` | `Entity` | Enterprise entity base (attribute bag, casts, dirty tracking) | [`Entity/README.md`](Entity/README.md) | -| `Seo/` | `RichGraph`, `SeoHead`, sitemap toolkit, `RouteCatalog`, `IndexNowKey` | SEO/sitemap/JSON-LD building | [`Seo/README.md`](Seo/README.md) | - -Deeper AI context: `docs/ai-context/27_ENTITY_SUPPORT.md` (casting + entity) and -`docs/ai-context/29_PROJECT_LAYER.md` (the whole `Project\` layer). - ---- - -## `Arr` — static array helpers - -Dot-notation aware. Used internally by `Collection`; fine to use standalone. - -```php -use Project\Support\Arr; - -Arr::get($row, 'billing.address.city', 'n/a'); // dot path, default on miss -Arr::get($row, null); // null key → the whole array -Arr::set($payload, 'meta.source', 'import'); // creates intermediate arrays, by reference -Arr::has($row, 'billing.address'); // true even when the value is null - -Arr::only($row, ['id', 'email']); // key-preserving subset -Arr::except($row, ['password_hash']); - -Arr::first($rows); // first value, or $default when empty -Arr::first($rows, fn($v, $k) => $v['active']); // first match; callback gets ($value, $key) - -Arr::flatten($nested); // full depth -Arr::flatten($nested, depth: 1); // one level only - -Arr::pluck($rows, 'name'); // list of values -Arr::pluck($rows, 'name', 'id'); // ['id-1' => 'name', …] - -Arr::isAssoc($array); // keys are NOT 0..n-1 -``` - -Notes worth knowing: - -- `get()` checks `array_key_exists($key, …)` FIRST, so a literal key containing - a dot (`'a.b' => 1`) wins over the dot path — that is intentional. -- `has()` distinguishes "missing" from "present but null" via an internal - sentinel; `isset()`-style checks do not. -- `pluck()` reads array items by dot path and object items by public property. - ---- - -## `Str` — static string helpers - -```php -use Project\Support\Str; - -Str::studly('order_line'); // 'OrderLine' -Str::camel('order_line'); // 'orderLine' -Str::snake('OrderLine'); // 'order_line' -Str::kebab('OrderLine'); // 'order-line' -Str::slug('Héllo World!'); // 'hello-world' (unicode letters/numbers kept) - -Str::startsWith($path, '/api'); // false for an EMPTY needle (unlike str_starts_with) -Str::endsWith($file, '.php'); -Str::contains($ua, 'Mobile'); - -Str::limit($text, 120); // mb-safe truncate + '...' -Str::random(32); // hex, from random_bytes — cryptographically strong -``` - -`startsWith`/`endsWith`/`contains` return **false** on an empty needle by -design, so `Str::contains($x, '')` cannot accidentally pass a filter. Use the -native `str_*` functions when you want PHP's empty-needle semantics. - ---- - -## `Collection` — fluent array wrapper - -`final class Collection implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable`. -Transforming methods return a **new** Collection; only `push()`/`put()` mutate -and return `$this`. Original implementation — no Laravel dependency. - -```php -use Project\Support\Collection; - -$total = Collection::make($orderRows) - ->filter(fn(array $r) => $r['status'] === 'paid') - ->sortBy('created_at') - ->sum('amount_cents'); - -$byCustomer = Collection::make($orders)->groupBy('customer_id'); // Collection -``` - -| Group | Methods | -|---|---| -| Build | `__construct(iterable)`, `make()` | -| Read | `all()`, `count()`, `isEmpty()`, `isNotEmpty()`, `get()`, `has()`, `contains()`, `first()`, `last()` | -| Transform | `map()`, `filter()`, `reject()`, `pluck()`, `keys()`, `values()`, `unique()`, `reverse()`, `slice()`, `take()`, `chunk()`, `flatten()`, `merge()` | -| Order/group | `sort()`, `sortBy()`, `groupBy()`, `where()` | -| Aggregate | `reduce()`, `sum()`, `avg()`, `min()`, `max()`, `implode()` | -| Side effects | `each()` (return `false` to break), `push()`, `put()`, `pipe()` | -| Export | `toArray()`, `toJson()`, `jsonSerialize()`, `getIterator()` | - -Behaviour that is easy to get wrong: - -- `map()` and `filter()` receive **`($value, $key)`** and **preserve keys**. - Call `->values()` when you need a JSON list. -- `contains()` accepts a value (strict `in_array`) **or** a predicate. -- `chunk()` returns a Collection **of Collections**; `toArray()` unwraps nested - Collections recursively. -- `sortBy()`/`where()`/`sum()` cast each item with `(array)` before the dot - lookup, so they work on arrays and on objects with public state. -- `toJson()` always sets `JSON_THROW_ON_ERROR`. - ---- - -## `Resource` / `ResourceCollection` — API output shaping - -Keeps "what the API returns" out of controllers and services. One subclass per -output shape; the controller stays at its 3-line limit. - -```php -use Project\Support\Resource; - -final class UserResource extends Resource -{ - public function toArray(): array - { - return [ - 'id' => $this->get('id'), - 'name' => $this->get('fullName'), // property OR getter — see below - 'email' => $this->get('email'), - ]; - } -} - -return $this->ok(UserResource::make($user)->toArray()); -return $this->ok(UserResource::collection($users)->toArray()); // list of shaped items -``` - -- `Resource::make($resource): static` wraps one item; `Resource::collection(iterable): ResourceCollection` - wraps many (each item constructed through the same resource class, lazily on - `toArray()`). -- The protected `get(string $key, mixed $default = null)` reads the wrapped - value whether it is an **array key**, a **public property**, or a **method** - (`$user->fullName()`), in that order. -- Both implement `JsonSerializable` and expose `toJson()` with - `JSON_THROW_ON_ERROR`. - -Resources are a **presentation** concern: never put authorization, persistence -or event dispatch inside `toArray()`. - ---- - -## `Hydration\DataConverter` — the row ⇄ object bridge - -The Repository's hydrator. Runs every mapped field through the -[`DataCaster`](Casting/README.md) and builds/consumes domain objects through -explicit seams — there is **no reflection back-door** that writes private -properties. - -```php -use Project\Support\Hydration\DataConverter; - -$converter = new DataConverter( - types: [ - 'id' => 'int', - 'is_active' => 'int-bool', // 0/1 column ⇄ bool - 'meta' => '?json[array]', // nullable JSON ⇄ assoc array - 'created_at' => 'datetime', - ], - castHandlers: ['money' => MoneyCast::class], // optional custom casts - helper: null, // optional object passed to casts - reconstructor: 'reconstitute', // static factory name, or a Closure - extractor: 'toRawArray', // method name, or a Closure -); - -$invoice = $converter->reconstruct(Invoice::class, $row); // DB row → object -$columns = $converter->extract($invoice); // object → DB columns - -$phpRow = $converter->fromDataSource($row); // row array → PHP-typed array -$dbRow = $converter->toDataSource($phpRow); // PHP array → DB-typed array -``` - -- `reconstruct()` calls `$classname::$reconstructor($phpData)`. If that static - factory does not exist it throws `RuntimeException` telling you to pass a - closure — it will not reach into private state. -- `extract()` calls the named method when present; otherwise it falls back to a - `(array)` cast that keeps **public state only** (private/protected keys are - NUL-mangled and dropped). Give entities a real `toRawArray()`. -- Casters are pooled statically by a hash of `types + castHandlers`, so building - a `DataConverter` per repository call is cheap. Casts are stateless → - OpenSwoole-safe. - -Full casting grammar (`?type`, `type[param,param]`, the 11 built-ins, writing a -custom `CastInterface`) lives in [`Casting/README.md`](Casting/README.md). - ---- - -## Rules - -``` -✓ Support classes are DI-free value utilities — construct them inline, never bind them. -✓ Safe to call from Domain/, Application/ and Infrastructure/ — they import nothing external. -✓ Collection/Arr/Str are ORIGINAL implementations — do not "align" them with Laravel's API. -✓ Shape API output in a Resource subclass; keep controllers at 3 lines. -✓ Hydrate via DataConverter::reconstruct() (or Entity::reconstitute()); persist via extract()/toRawArray() + $db->upsert(). -✗ Adding I/O, env reads, or container lookups to anything under Support/ — that belongs in a plugin. -✗ Business rules inside Resource::toArray() — it is presentation only. -✗ Relying on Collection::map()/filter() to renumber keys — call ->values(). -✗ Reflection-based hydration — expose a static reconstitute()/toRawArray() seam instead. -``` diff --git a/projects/Support/Seo/README.md b/projects/Support/Seo/README.md deleted file mode 100644 index 7163a93..0000000 --- a/projects/Support/Seo/README.md +++ /dev/null @@ -1,267 +0,0 @@ -# `Project\Support\Seo` — SEO, Sitemap & Rich-Result Toolkit - -> Namespace `Project\Support\Seo\` → `projects/Support/Seo/`. - -Project-layer, **DI-free** helpers for building sitemaps, `` metadata and -Schema.org JSON-LD. They autoload directly, so **no module load is required** to -build a sitemap, an Open Graph block or a rich-results graph. - -Only NETWORK actions (sitemap ping, IndexNow submission) go through the -**SiteSEO plugin** (`Plugins\SiteSEO`, solves `seo.management`) because outbound -HTTP must travel via `HttpClientPort`. A route doing that declares -`"requires": ["seo.management"]`. - -| Need | Where | -|---|---| -| Build sitemap XML / JSON-LD / `` | these classes — no module needed | -| Ping search engines, submit IndexNow | `SeoServiceContract` (SiteSEO plugin) | -| Type/OpenGraph primitives (`Type`, `Image`, `Schema`, `RobotsTxtEditor`) | SiteSEO plugin (used by `SeoHead`/`RichGraph`) | - -Controller ergonomics live in `Project\Http\Controllers\Concerns\InteractsWithSeo` -and `InteractsWithGraphSeo` — see [`../../Http/Controllers/README.md`](../../Http/Controllers/README.md). - ---- - -## Class map - -| Class | Role | -|---|---| -| `RouteCatalog` | Reads the compiled route manifest → the site's public static GET paths | -| `SitemapGenerator` | Small/route-derived sitemaps (in-memory, one `` + index) | -| `SitemapStreamWriter` | Enterprise: streams an `iterable` to split child files + index, **O(1) memory** | -| `SitemapUrlProvider` (interface) | Expands ONE dynamic route pattern (`/blog/{slug}`) lazily from the DB | -| `SitemapSource` | Stitches static routes + providers; reports uncovered dynamic routes | -| `RichGraph` | Schema.org JSON-LD `@graph` builder — nodes cross-linked by `@id` | -| `SeoHead` | One-call ``: title, description, canonical, robots, hreflang, OG, JSON-LD | -| `IndexNowKey` | IndexNow key value object (value, key-file contents, keyLocation) | - ---- - -## `RouteCatalog` — what pages actually exist - -```php -use Project\Support\Seo\RouteCatalog; - -$catalog = RouteCatalog::fromManifest(); // default manifest path -$catalog = RouteCatalog::fromManifest($path); // explicit route-manifest.php - -$paths = $catalog->publicPaths(); // ['/', '/about', '/pricing', …] -$paths = $catalog->publicPaths( - excludePrefixes: ['/internal'], - excludePaths: ['/health'], -); - -// A sitemap describes ONE host. Pass it to include that host's domain-grouped -// pages alongside the shared ones; the default (null) is shared-only. -$paths = $catalog->publicPaths(domain: 'africavoting.local'); - -$catalog->all(); // raw manifest, keyed "METHOD /path" -``` - -`publicPaths()` keeps only entries that are **GET**, **static** (no `{param}` — -those cannot be enumerated from a manifest), **not auth-gated**, and not under -the default excluded prefixes/paths (API, SEO endpoints, …). Results are -deduped, so a project route overriding a plugin route appears once. - -**Domain groups.** A route may be grouped under a host, and its manifest key is -then `METHOD@host /path`. `publicPaths()` skips every grouped route by default, -so an existing single-host sitemap is byte-identical. Passing `domain:` expands -that host into its candidate groups (exact, wildcard, bare subdomain) and -includes those pages plus the shared ones. - -Dynamic routes are deliberately dropped here — feed them through a -`SitemapUrlProvider` instead. - ---- - -## Sitemaps - -### `SitemapGenerator` — small / route-derived sets - -Builds ONE `` in memory (keep it ≤ ~30k URLs). - -```php -use Project\Support\Seo\SitemapGenerator; - -$xml = SitemapGenerator::for('https://example.com') - ->named('pages') // child sitemap name (default "pages") - ->indexedAs('sitemap.xml') // index filename (default "sitemap.xml") - ->fromRoutes($catalog, priority: '0.8', changeFreq: 'weekly') - ->add('/launch', priority: '1.0', changeFreq: 'daily', lastMod: '2026-08-01') - ->addMany(['/a', '/b']) - ->toXml(); // or ->save($directory) → index path - -$count = $generator->count(); -``` - -### `SitemapStreamWriter` — millions of URLs, flat memory - -No DOM, no array buffer: writes straight to file handles, auto-splitting at -`maxPerFile` and writing the index for you. - -```php -use Project\Support\Seo\SitemapStreamWriter; - -$writer = new SitemapStreamWriter( - baseUrl: 'https://example.com', - maxPerFile: 50000, // sitemap protocol maximum - gzip: false, // true → .xml.gz children - indexName: 'sitemap.xml', -); - -$result = $writer->write($publicDir, $urls); -// ['index' => '/…/sitemap.xml', 'sitemaps' => ['/…/sitemap-1.xml', …], 'urls' => 1_240_337] - -// Or stream one urlset straight to the HTTP response, no buffering: -return Response::stream(fn() => $writer->echoStream($urls)); -``` - -Each `$urls` item is a path/URL string **or** -`['loc'|'path' => …, 'lastmod' => …, 'changefreq' => …, 'priority' => …]`. -`echoStream()` flushes every 1000 rows so memory stays flat and bytes reach the -client immediately. - -### `SitemapUrlProvider` + `SitemapSource` — dynamic routes - -Implement one provider per dynamic route pattern; yield from a **keyset cursor** -so the DB read is also O(1) in memory. - -```php -final class BlogSitemapProvider implements SitemapUrlProvider -{ - public function pattern(): string { return '/blog/{slug}'; } - - public function urls(): iterable - { - $lastId = 0; - while ($rows = $this->db->query( - 'SELECT id, slug, updated_at FROM posts - WHERE id > :last AND published = 1 ORDER BY id LIMIT 5000', - ['last' => $lastId] - )) { - foreach ($rows as $r) { - $lastId = (int) $r['id']; - yield ['loc' => '/blog/' . $r['slug'], 'lastmod' => $r['updated_at']]; - } - } - } -} - -$source = new SitemapSource($catalog, [new BlogSitemapProvider($db)]); - -$writer->write($dir, $source->all()); // static pages, then every provider — lazily - -$source->dynamic(); // provider URLs only (Generator) -$source->dynamicRoutes(); // '{param}' routes found in the manifest -$source->coveredPatterns(); // patterns a provider claims -$source->uncoveredDynamicRoutes(); // ← guard: dynamic routes with NO provider -``` - -`uncoveredDynamicRoutes()` is the omission guard — assert on it in a test or log -it in the sitemap command so a new dynamic route cannot silently vanish from the -sitemap. - ---- - -## `RichGraph` — Schema.org JSON-LD `@graph` - -One graph per page. Nodes are created with helper methods and cross-linked by -`@id`, which is what Google's rich-result parsers expect — not a pile of -disconnected blobs. - -```php -use Project\Support\Seo\RichGraph; - -$graph = RichGraph::for('https://example.com') - ->organization('Example Ltd', logo: '/img/logo.png', sameAs: ['https://x.com/example']) - ->website(name: 'Example', searchUrl: '/search?q={search_term_string}') - ->webPage('/blog/hello', 'Hello world', 'An introduction') - ->breadcrumb([['name' => 'Blog', 'url' => '/blog'], ['name' => 'Hello', 'url' => '/blog/hello']]) - ->blogPosting( - url: '/blog/hello', headline: 'Hello world', - description: 'An introduction', image: '/img/hero.jpg', - datePublished: '2026-08-01', dateModified: '2026-08-05', - authorName: 'Sam Doe', authorUrl: '/authors/sam', tags: ['php', 'gda'], - ); - -echo $graph; // -$graph->toArray(); // raw JSON-LD structure -$graph->toSchema(); // SiteSEO Schema instance (single node stays flat) -``` - -Node builders: - -| Method | Emits | -|---|---| -| `organization($name, $logo, $sameAs)` | `Organization` — publisher/author anchor | -| `website($name, $searchUrl)` | `WebSite` (+ `SearchAction` sitelinks searchbox) | -| `webPage($url, $name, $description)` | `WebPage` — the page node other nodes hang off | -| `breadcrumb($items)` | `BreadcrumbList` | -| `article(…)` / `newsArticle(…)` / `blogPosting(…)` | `Article` and its subtypes, wired to page + org + author | -| `product($url, $name, …, $offer, $rating, $review)` | `Product` + `Offer` + `AggregateRating` + `Review` | -| `book(…)`, `course(…)`, `realEstate(…)` | `Book`, `Course`, real-estate listing | -| `pageantEdition(…)`, `awardEdition(…)`, `contestant(…)` | `Event` + `Person` performers (contestants / nominees) | -| `faq($qa)` | `FAQPage` from a `question => answer` map | -| `node($type, $data)` | any node type there is no helper for | - -Every builder returns `$this`, so a page is one fluent chain. - ---- - -## `SeoHead` — the whole `` in one call - -```php -use Project\Support\Seo\SeoHead; - -echo SeoHead::for('https://example.com') - ->title('Hello world — Example') - ->description('An introduction to the platform.') - ->canonical('/blog/hello') - ->robots(index: true, follow: true, maxImagePreview: 'large', maxSnippet: 160) - ->hreflang('fr', '/fr/blog/hello') - ->xDefault('/blog/hello') - ->openGraph($ogType) // Plugins\SiteSEO\Type (build it via InteractsWithSeo::openGraph()) - ->graph($graph) // RichGraph — rendered as JSON-LD inside the head - ->render(); -``` - -`noindex()` is the one-call shortcut for `robots(index: false, follow: false)` — -use it on staging, private, and thin pages. `render()` and `__toString()` are -equivalent. - ---- - -## `IndexNowKey` - -```php -use Project\Support\Seo\IndexNowKey; - -$key = IndexNowKey::generate(); // 32 hex chars — publish once, keep stable -$key = IndexNowKey::fromString(env('INDEXNOW_KEY')); // validates ^[a-zA-Z0-9-]{8,128}$ - -$key->value(); // the key itself -$key->fileContents(); // body of the key file to serve -$key->path(); // '' → canonical "/{key}.txt" at the site root -$key->location('https://example.com'); // absolute keyLocation for the API call - -$key = $key->publishedAt('/seo/indexnow.txt'); // immutable — returns a NEW instance -``` - -Submission itself goes through the SiteSEO plugin -(`SeoServiceContract::indexNow(...)` / `indexNowChunks()` + the `seo.indexnow` -job), never from these classes. - ---- - -## Rules - -``` -✓ Sitemap/OG/JSON-LD building is DI-free — no module load, no container. -✓ Network actions (ping, IndexNow) need requires:["seo.management"] → HttpClientPort. -✓ Huge sitemaps → SitemapStreamWriter over a generator (keyset DB cursor), never an array/DOM. -✓ Every dynamic route gets a SitemapUrlProvider; assert uncoveredDynamicRoutes() is empty. -✓ One JSON-LD @graph per page (RichGraph), nodes linked by @id. -✗ Raw cURL for ping/IndexNow — always the SiteSEO gateway + HttpClientPort. -✗ Buffering a whole catalogue in memory to build a sitemap or submit IndexNow. -✗ Hardcoding the host — take the base URL from the request/DomainContext (InteractsWithSeo::siteBaseUrl()). -``` From 299a2715c743a5947c8e357c257a149f8763cc77 Mon Sep 17 00:00:00 2001 From: Hakeem Shamavu Date: Mon, 17 Aug 2026 17:26:46 +0300 Subject: [PATCH 140/140] fix(cli): scope-aware install, upgrade and version reporting A machine can hold both a system (.deb) and a user (tarball) install. The CLI did not model that: a shared config.env HKM_KERNEL_HOME pin let either install redirect the other's kernel, and hkm upgrade could only ever update the system scope. Both made installing or upgrading appear to do nothing. Release 1.3.2. --- CHANGELOG.md | 95 +++ tools/README.md | 75 +++ tools/build.zig | 6 +- tools/install.sh | 110 ++- tools/src/commands/doctor.zig | 63 +- tools/src/commands/upgrade.zig | 627 +++++++++++++----- tools/src/commands/version.zig | 252 +++++++ tools/src/config.zig | 70 +- tools/src/lib/composer_version.zig | 576 ++++++++++++++++ tools/src/lib/install_scope.zig | 415 ++++++++++++ tools/src/lib/kernel.zig | 277 ++++++-- tools/src/lib/userconfig.zig | 111 ++++ tools/src/main.zig | 12 +- tools/src/stamp.zig | 425 +----------- .../app/bootstrap/kernel-autoload.php | 18 +- tools/src/tests.zig | 3 + 16 files changed, 2452 insertions(+), 683 deletions(-) create mode 100644 tools/src/commands/version.zig create mode 100644 tools/src/lib/composer_version.zig create mode 100644 tools/src/lib/install_scope.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 39cf398..778e89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,101 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.2] - 2026-08-17 + +Fixes a class of failure that made installing or upgrading on a machine with an +existing install appear to do nothing. A machine can hold BOTH a system install +(`.deb` → `/opt/hkm-kernel` + `/usr/bin`) and a user install (tarball → +`~/.local`); the CLI did not model that, and every symptom below followed from +the same gap. + +**If you are upgrading from 1.3.1 or earlier, the old launcher cannot install +the user scope.** Install it from the release instead — the fixed `hkm upgrade` +takes over from there: + +```sh +curl -fsSL https://github.com/AlfaCode-Team/hkm-kernel/releases/latest/download/install.sh | sh +hkm version # shows every install and which one your PATH runs +``` + +### Added +- **`hkm version` reports every install on the machine**, not just the launcher's + own compile-time stamp: the kernel version in each scope (read from that + kernel's `composer.json`), the launcher serving it and the version IT was built + as, and an arrow on the one this invocation resolves. It also names the states + that make a later "my upgrade did nothing" report inevitable — another `hkm` + earlier on `PATH`, a kernel with no `vendor/`, a stale config pin. `hkm + --version` is unchanged and still prints one line for scripts. +- **`hkm upgrade --user` / `--system`** to force a scope. Without either, the + target is chosen from privilege — root → system, otherwise → user — so + `sudo hkm upgrade` and `hkm upgrade` are two predictable commands rather than + one command whose target depends on machine state. +- **`hkm-config unset `**, for clearing a stale `HKM_KERNEL_HOME`. +- `hkm doctor` gained an **Installs** table: both scopes, their versions and + whether each has resolved dependencies. + +### Changed +- **Kernel resolution ranks sources by how specific they are to the invocation** + (`tools/src/lib/kernel.zig`): an exported `HKM_CLI_PATH` / `HKM_KERNEL_HOME`, + then self-location relative to the launcher's own binary, then a + `config.env` pin, then `/opt/hkm-kernel`. The pin was previously checked + first. It still applies wherever self-location genuinely fails — a custom + prefix — but no longer overrides an install sitting next to the binary. + A launcher in a system bin directory (`/usr/bin`) claims `/opt/hkm-kernel` at + the self-location step, since no relative probe can reach it from there. +- **`hkm upgrade --user` installs to `~/.local/lib/hkm-kernel`**, matching + `install.sh`, instead of `~/.local/share/hkm/kernel`. The old path sits outside + every self-location probe, so it could only ever be reached through a + machine-wide pin — which is what created the cross-scope hijack below. An + install left at the old location is detected and reported, not silently used. +- **`install.sh` removes a redundant or superseded `HKM_KERNEL_HOME` pin** + rather than repointing it. A repointed pin is still read by every launcher on + the machine; no pin lets each one find its own kernel. A pin aimed at a genuine + custom layout is reported and left alone. It also lists the installs already + present with their versions, and prints `Version: old -> new` when it finishes. +- **`hkm-config check` no longer pins `HKM_KERNEL_HOME` for a self-locating + layout** — writing one on behalf of whichever install ran it last is how the + shared pin came to exist. It removes one that has become redundant. +- `hkm upgrade --local` obeys the same scope rule (non-root installs to the user + scope, creating it if absent) and installs the launcher into that scope's `bin` + directory rather than always `/usr/bin`. +- The scaffolded `kernel-autoload.php` tries `~/.local/lib/hkm-kernel` before + `/opt/hkm-kernel`, so a project run under PHP-FPM or systemd resolves the + kernel its owner actually manages. The pre-1.4 user path is still tried. + +### Fixed +- **One install silently ran the other's kernel.** `~/.config/hkm/config.env` is + read by every `hkm` on the machine, and `HKM_KERNEL_HOME` was checked before + self-location — so whichever installer wrote that pin last redirected the other + install too. A `.deb` launcher would report its own version while running a + kernel out of the user's home, and upgrading either scope could not move the + number on screen. +- **`hkm upgrade` could not update a user install on Linux.** It only ever + fetched the `.deb` and shelled out to `sudo apt-get`, despite the user-local + tarball being the documented default since 1.3.1. Because `PATH` usually + resolves `~/.local/bin` before `/usr/bin`, the command reported success and the + very next invocation ran the old launcher unchanged. The user scope now + installs from the tarball via its own `install.sh`, with no `sudo` anywhere in + that path. +- **Upgrade decisions used the wrong version.** `hkm upgrade` compared the + LAUNCHER's compile-time stamp against the latest release tag, then went on to + replace a KERNEL somewhere else — two numbers that differ exactly when the + launcher on `PATH` belongs to the other scope. Versions are now read from the + kernel being replaced, and the command names the other scope when it is also + behind instead of reporting an unqualified "you are on the latest version". +- **A `--local` install could never report what it was.** It copied the + checkout's `composer.json`, which carries no `version` field by design, so + `hkm version` read "unstamped" forever and the next upgrade had nothing to + compare. The `git describe` version is now recorded as semver build metadata + (`1.3.1-2-g34abb2c` → `1.3.1+2.g34abb2c`), which Composer accepts and which + semver excludes from precedence — a change of spelling, not of meaning. A + release build still stamps the exact tag or nothing. +- A `--system` upgrade run without root now says so once, up front, with the + command that works, instead of failing one permission error at a time. The + system path no longer prefixes `sudo` unconditionally, which broke on the + containers and CI images where a system install is most useful and `sudo` is + frequently absent. + ## [1.3.1] - 2026-08-12 Supersedes 1.3.0, which was tagged from a commit that never reached `master` diff --git a/tools/README.md b/tools/README.md index 99f8ea8..6a777e0 100644 --- a/tools/README.md +++ b/tools/README.md @@ -79,6 +79,81 @@ Note `/usr/bin` normally precedes `~/.local/bin` on `PATH`, so a leftover `.deb` install silently shadows a user install. `install.sh` warns when it sees one; remove it with `sudo apt remove hkm-kernel`. +## Two installs on one machine + +A system install and a user install **coexist by design** and are updated +separately. Everything below follows from that, and `hkm version` is the command +that shows the whole picture at once: + +``` +$ hkm version + scope kernel kernel version launcher + system /opt/hkm-kernel 1.3.1 /usr/bin/hkm (1.3.1) +→ user ~/.local/lib/hkm-kernel 1.4.0 ~/.local/bin/hkm (1.4.0) +``` + +Three versions are in play and they can all differ: the **launcher** binary's +compile-time stamp, the **kernel** on disk (from its `composer.json`), and one +of each per scope. `hkm --version` still prints just this launcher's, for +scripts. + +### Which install does `hkm upgrade` touch? + +Privilege decides, so the two forms are two predictable commands rather than one +command with a machine-dependent target: + +| Command | Target | Artifact | +|---|---|---| +| `hkm upgrade` | `~/.local` (this user) | the linux `.tar.gz` + its `install.sh` | +| `sudo hkm upgrade` | `/opt` + `/usr/bin` | the `.deb`, via apt | +| `hkm upgrade --user` / `--system` | forces either | as above | + +`hkm upgrade --check` reports the scope you asked about and names the *other* +one when it is also behind — because "you are on the latest version" is +misleading when the launcher your `PATH` resolves belongs to the scope that was +not checked. + +Versions come from the **kernel being replaced**, never from `banner.version()`. +Comparing the launcher's compile-time stamp to the latest tag answered "is this +binary current" while the command went on to replace a kernel somewhere else. + +### Kernel resolution, and why a config pin no longer wins + +`~/.config/hkm/config.env` is read by **every** `hkm` on the machine. When +`HKM_KERNEL_HOME` was checked first, whichever installer wrote it last silently +redirected the other install: + +``` +$ /usr/bin/hkm --version → 1.3.1 # the .deb's launcher +$ /usr/bin/hkm doctor + kernel root ~/.local/share/hkm/kernel # …the USER's kernel + resolved via HKM_KERNEL_HOME override +``` + +Upgrading either scope then looked like a no-op. Resolution now ranks sources by +how specific they are to *this* invocation (`src/lib/kernel.zig`): + +1. `HKM_CLI_PATH` / `HKM_KERNEL_HOME` **exported in the real environment** +2. **self-location** relative to the launcher's own executable — per-install by + construction, so the other scope cannot affect it. A launcher in a system bin + dir (`/usr/bin`) claims `/opt/hkm-kernel` here, since no relative probe can + reach it from there +3. `HKM_KERNEL_HOME` from `config.env` — now a **fallback**, for custom layouts + self-location genuinely cannot find +4. `/opt/hkm-kernel` + +So a pin still works wherever it was actually needed; it no longer overrides an +install sitting next to the binary. `hkm-config check` writes one only when +self-location failed, and `hkm-config unset HKM_KERNEL_HOME` clears a stale one. + +### `hkm upgrade --local` + +Installs the current checkout over an installed kernel, obeying the same scope +rule (non-root → your user install, which it creates if absent). It stamps the +`git describe` version into the destination `composer.json`, so the result can +report what it is — without that, a locally installed kernel read `unstamped` +forever and had nothing to compare on the next upgrade. + ## Layout ``` diff --git a/tools/build.zig b/tools/build.zig index b87debe..8e4f433 100644 --- a/tools/build.zig +++ b/tools/build.zig @@ -59,9 +59,13 @@ pub fn build(b: *std.Build) void { // imports like `@import("../constants.zig")` resolve inside the module. // Running `zig test src/lib/memory.zig` directly makes src/lib the module // root and that import fails, which is misleading rather than useful. + // The stamper's logic lives in lib/composer_version.zig — it is shared with + // `hkm version` / `hkm upgrade`, which READ the field the stamper writes. + // Testing the library rather than the executable wrapper is what keeps the + // read and write halves verified against each other. const stamp_tests = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("src/stamp.zig"), + .root_source_file = b.path("src/lib/composer_version.zig"), .target = target, .optimize = optimize, }), diff --git a/tools/install.sh b/tools/install.sh index 487c9b6..23617d6 100755 --- a/tools/install.sh +++ b/tools/install.sh @@ -83,13 +83,44 @@ if [ "$DO_UNINSTALL" -eq 1 ]; then exit 0 fi -# ── a system install would shadow this one ────────────────────────────────── +# ── what is already on this machine ───────────────────────────────────────── +# Installing over an existing install is the ordinary case, not an error — but +# the two are independent, upgrade separately, and PATH silently decides which +# launcher a bare `hkm` runs. Reporting both up front is what turns "I installed +# it and the version did not change" into something the reader can see coming. +kernel_version() { # $1 = kernel root → prints the stamped version, or nothing + [ -f "$1/composer.json" ] || return 0 + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1/composer.json" | head -1 +} + +SYS_ROOT=/opt/hkm-kernel +SYS_VER="$(kernel_version "$SYS_ROOT")" +OLD_VER="$(kernel_version "$DEST")" + +if [ -d "$SYS_ROOT" ] || [ -d "$DEST" ]; then + say "Existing installs on this machine:" + [ -d "$SYS_ROOT" ] && printf ' system %-28s %s\n' "$SYS_ROOT" "${SYS_VER:-unstamped}" + [ -d "$DEST" ] && printf ' user %-28s %s\n' "$DEST" "${OLD_VER:-unstamped}" +fi + # /usr/bin usually precedes ~/.local/bin on PATH, so a leftover .deb install # silently wins and the user debugs the wrong copy. -if [ -x /usr/bin/hkm ] && [ "$PREFIX" = "$HOME/.local" ]; then +if [ -x /usr/bin/hkm ] && [ "$BINDIR" = "$HOME/.local/bin" ]; then warn "A system-wide hkm exists at /usr/bin/hkm (installed from the .deb)." - warn "It will take priority on PATH over this user install." - warn "Remove it first with: sudo apt remove hkm-kernel" + warn "It comes FIRST on PATH, so a bare 'hkm' will still run that one." + warn "Either remove it (sudo apt remove hkm-kernel), or put $BINDIR ahead of" + warn "/usr/bin in your PATH. 'hkm version' shows both installs at any time." +fi + +# The pre-1.4 'hkm upgrade --user' target. It is NOT self-locatable, so it could +# only ever be reached through a config pin — and that pin is read by every +# launcher on the machine, which is how a user install came to redirect the +# system launcher's kernel. Nothing writes there now; say it is being left. +LEGACY_USER="${XDG_DATA_HOME:-$HOME/.local/share}/hkm/kernel" +if [ -f "$LEGACY_USER/composer.json" ] && [ "$LEGACY_USER" != "$DEST" ]; then + warn "An older user kernel remains at $LEGACY_USER" + warn "It is superseded by this install and is no longer updated." + warn "Delete it once 'hkm version' shows the new one active." fi # ── acquire the tarball ───────────────────────────────────────────────────── @@ -190,22 +221,38 @@ cp "$SRC/bin/hkm-config" "$BINDIR/hkm-config" chmod +x "$BINDIR/hkm" "$BINDIR/hkm-config" ok "Installed to $DEST" -# ── repoint a stale kernel pin ────────────────────────────────────────────── -# This is not cosmetic. The launcher loads ~/.config/hkm/config.env into its -# environment BEFORE resolving (main.zig), and resolveHome() checks -# HKM_KERNEL_HOME FIRST — ahead of self-location. So a pin left over from an -# earlier install at a different path silently wins, and `hkm` keeps running the -# OLD kernel while this one sits unused. Repoint it, or self-location never gets -# a look in. +# ── drop a kernel pin this install makes redundant ────────────────────────── +# The launcher loads ~/.config/hkm/config.env into its environment before +# resolving. That file is shared by EVERY hkm on the machine, so a pin written +# for one install redirected the other one too — a .deb launcher reporting 1.3.1 +# while running a kernel out of the user's home. +# +# The bin/ + lib/ layout above is self-locating (the launcher probes +# "/lib/hkm-kernel"), so this install needs no pin at +# all. Removing one is therefore strictly better than repointing it: a repointed +# pin still applies machine-wide, while no pin lets each launcher find its own +# kernel. A pin aimed somewhere ELSE is an operator's deliberate choice about a +# custom layout and is only reported. CFG="${XDG_CONFIG_HOME:-$HOME/.config}/hkm/config.env" if [ -f "$CFG" ]; then PINNED="$(sed -n 's/^[[:space:]]*HKM_KERNEL_HOME[[:space:]]*=[[:space:]]*//p' "$CFG" | tail -1)" - if [ -n "$PINNED" ] && [ "$PINNED" != "$DEST" ]; then - warn "config.env pins HKM_KERNEL_HOME=$PINNED" - warn "That would override this install. Repointing it to $DEST" - "$BINDIR/hkm-config" set-kernel-home "$DEST" >/dev/null 2>&1 \ - || die "could not repoint HKM_KERNEL_HOME — edit $CFG by hand, then re-run" - ok "Repointed HKM_KERNEL_HOME" + if [ -n "$PINNED" ]; then + if [ "$PINNED" = "$DEST" ]; then + "$BINDIR/hkm-config" unset HKM_KERNEL_HOME >/dev/null 2>&1 \ + && ok "Removed the redundant HKM_KERNEL_HOME pin (the launcher self-locates)" + elif [ "$PINNED" = "$LEGACY_USER" ]; then + # The pre-1.4 '--user' target. Not a custom layout an operator chose — a + # location this very install supersedes, and the one a machine that hit + # the cross-scope hijack is pinned to. Leaving it would keep a superseded + # kernel as the fallback for every launcher here, so remove it: the user + # kernel it named has just been replaced by the one at $DEST. + "$BINDIR/hkm-config" unset HKM_KERNEL_HOME >/dev/null 2>&1 \ + && ok "Removed the HKM_KERNEL_HOME pin to the superseded $PINNED" + else + warn "config.env pins HKM_KERNEL_HOME=$PINNED" + warn "This install does not need it, and it is shared with every other hkm" + warn "on this machine. Clear it with: hkm-config unset HKM_KERNEL_HOME" + fi fi fi @@ -247,13 +294,16 @@ case ":${PATH}:" in ;; esac -# ── pin config + move the registry out of the kernel tree ─────────────────── -# `hkm-config check` is the canonical step: it pins HKM_KERNEL_HOME, then -# creates ~/.local/share/hkm and MIGRATES projects.json + platform.json out of -# the kernel tree into it (ensureUserdata in config.zig). After this, an upgrade -# cannot touch the registry at all — it no longer lives in the replaced tree. +# ── move the registry out of the kernel tree ──────────────────────────────── +# `hkm-config check` creates ~/.local/share/hkm and MIGRATES projects.json + +# platform.json out of the kernel tree into it (ensureUserdata in config.zig). +# After this, an upgrade cannot touch the registry at all — it no longer lives +# in the replaced tree. +# +# It no longer pins HKM_KERNEL_HOME for a self-locating layout like this one; +# see the pin section above for why a machine-wide pin was the wrong default. if [ -x "$BINDIR/hkm-config" ]; then - say "Pinning configuration…" + say "Checking configuration…" # It exits non-zero when vendor/ is absent, which is the expected state after # --no-composer — so only surface that as a problem when composer did run. if ! "$BINDIR/hkm-config" check >/dev/null 2>&1; then @@ -274,6 +324,17 @@ fi printf '\n' ok "Installed for $(id -un) only; nothing was written outside your home." + +# State the version transition explicitly. "Installed" with no number is what +# leaves someone unsure whether anything changed — especially when another +# install on the machine is what their PATH actually resolves. +NEW_VER="$(kernel_version "$DEST")" +if [ -n "$OLD_VER" ] && [ -n "$NEW_VER" ] && [ "$OLD_VER" != "$NEW_VER" ]; then + printf ' Version: %s -> %s\n' "$OLD_VER" "$NEW_VER" +elif [ -n "$NEW_VER" ]; then + printf ' Version: %s\n' "$NEW_VER" +fi + if [ "$DOCTOR_OK" -eq 0 ]; then printf ' Some requirements are not met yet — see "Must fix" above.\n' printf ' Installing PHP and its extensions needs an administrator; everything\n' @@ -283,3 +344,6 @@ printf ' Kernel: %s\n' "$DEST" printf ' Config: %s/hkm/config.env\n' "${XDG_CONFIG_HOME:-$HOME/.config}" printf ' Data: %s/hkm\n' "${XDG_DATA_HOME:-$HOME/.local/share}" printf ' Remove: %s --uninstall\n' "$0" +printf '\n' +printf ' Every install on this machine, and which one your PATH runs: hkm version\n' +printf ' Update this one later (no root): hkm upgrade\n' diff --git a/tools/src/commands/doctor.zig b/tools/src/commands/doctor.zig index 41c2463..2332ca2 100644 --- a/tools/src/commands/doctor.zig +++ b/tools/src/commands/doctor.zig @@ -24,6 +24,7 @@ const std = @import("std"); const builtin = @import("builtin"); const run_cmd = @import("run.zig"); +const install_scope = @import("../lib/install_scope.zig"); const kernel = @import("../lib/kernel.zig"); const prompt = @import("../lib/prompt.zig"); const userconfig = @import("../lib/userconfig.zig"); @@ -173,6 +174,46 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c } } + // ── Installs ──────────────────────────────────────────────────────────── + // + // Listed before the kernel section because the two scopes are the context + // everything below it is read in. A machine can hold both, they upgrade + // separately, and PATH silently decides which launcher a bare `hkm` runs — + // so "which kernel am I even looking at" has to be answered first. + const home_early = try kernel.resolveHomeDetailed(allocator, io, env); + prompt.section("Installs"); + var install_rows: std.ArrayList([]const []const u8) = .empty; + var any_install = false; + for ([_]install_scope.Scope{ .system, .user }) |sc| { + const inst = install_scope.detect(allocator, io, env, sc); + if (inst.present) any_install = true; + + const active: []const u8 = blk: { + const root = home_early.root orelse break :blk " "; + break :blk if (std.mem.eql(u8, util.trimSlash(root), util.trimSlash(inst.root))) "→" else " "; + }; + + try install_rows.append(allocator, try allocator.dupe([]const u8, &.{ + active, + sc.label(), + inst.root, + if (!inst.present) "not installed" else install_scope.versionLabel(inst.version), + if (!inst.present) "-" else if (inst.vendor) OK else "no vendor/", + })); + + if (inst.legacy_root) |legacy| { + rep.hint(try std.fmt.allocPrint( + allocator, + "a stale user kernel remains at {s} — migrate with `hkm upgrade --user`, then delete it", + .{legacy}, + )); + } + } + prompt.table(allocator, &.{ "", "scope", "kernel root", "version", "deps" }, install_rows.items); + if (!any_install) { + rep.hint("no kernel installed in either scope — `hkm upgrade --user` installs one without root"); + } + // ── Kernel ────────────────────────────────────────────────────────────── prompt.section("Kernel"); const k = try kernel.resolve(allocator, io, env); @@ -181,7 +222,7 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c prompt.item("cli present", if (k.exists) OK else "MISSING — reinstall or set HKM_KERNEL_HOME"); if (!k.exists) rep.fail("kernel CLI missing — reinstall, or: hkm-config set-kernel-home "); - const home_opt = try kernel.resolveHome(allocator, io, env); + const home_opt = home_early.root; var vendor_ok = false; if (home_opt) |home| { prompt.item("kernel root", home); @@ -234,15 +275,21 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c prompt.item("exists", if (util.fileExists(io, cfg)) "yes" else "no (defaults in use)"); } - // A pin that points somewhere other than the resolved kernel is the quiet - // failure this check exists for: the launcher reads config.env BEFORE - // self-locating, so a stale pin keeps an OLD kernel in use after a reinstall. + // A config-file pin is shared by EVERY launcher on the machine, so one left + // behind by a user install used to redirect the system launcher's kernel + // too. Self-location now outranks it (lib/kernel.zig), which makes a + // leftover pin harmless but still worth removing: it is consulted whenever + // a launcher cannot self-locate, and that is a hard failure to read. if (try userconfig.get(allocator, io, env, "HKM_KERNEL_HOME")) |pin| { - const stale = if (home_opt) |h| !std.mem.eql(u8, util.trimSlash(pin), util.trimSlash(h)) else true; prompt.item("HKM_KERNEL_HOME", pin); - if (stale) { - prompt.warn("pinned kernel differs from the one in use — the pin wins."); - rep.hint("repoint it: hkm-config set-kernel-home "); + const in_use = if (home_opt) |h| std.mem.eql(u8, util.trimSlash(pin), util.trimSlash(h)) else false; + if (home_early.source == .kernel_home_config) { + prompt.warn("this kernel comes from the config pin — the launcher could not self-locate one."); + } else if (!in_use) { + prompt.warn("the pin points at a different kernel than the one in use (self-location wins)."); + rep.hint("remove the stale pin: hkm-config unset HKM_KERNEL_HOME"); + } else { + rep.hint("the pin is redundant (self-location finds the same kernel): hkm-config unset HKM_KERNEL_HOME"); } } else { prompt.item("HKM_KERNEL_HOME", "not pinned (self-locating)"); diff --git a/tools/src/commands/upgrade.zig b/tools/src/commands/upgrade.zig index 52946b1..4e16643 100644 --- a/tools/src/commands/upgrade.zig +++ b/tools/src/commands/upgrade.zig @@ -1,14 +1,41 @@ -//! `hkm upgrade [--check]` — check for and apply kernel updates. +//! `hkm upgrade` — check for and apply kernel updates, per INSTALL SCOPE. //! -//! hkm upgrade --check # compare the installed version to the latest release -//! hkm upgrade # git checkout → pull + composer; packaged → guidance +//! hkm upgrade # update the install this user owns (~/.local) — no root +//! sudo hkm upgrade # update the system install (/opt + /usr/bin) +//! hkm upgrade --check # compare each scope's kernel to the latest release +//! hkm upgrade --local # install THIS checkout over an installed kernel +//! +//! WHY THE SCOPE SPLIT EXISTS +//! -------------------------- +//! Linux publishes TWO artifacts and the tarball is the primary one (see +//! tools/bundle.sh): a user-local tarball that needs no root, and a .deb for +//! multi-user machines. `hkm upgrade` only ever fetched the .deb and shelled +//! out to `sudo apt-get`, so: +//! +//! • a user install could not update itself at all — the command "succeeded", +//! updated /opt, and left ~/.local/bin/hkm exactly as it was; +//! • PATH usually resolves ~/.local/bin BEFORE /usr/bin, so the very next +//! command ran the old launcher and the version had not moved; +//! • and a non-root user was prompted for a password to update a copy of the +//! kernel they were not running. +//! +//! So the target is now chosen by privilege, which makes the two forms two +//! predictable commands rather than one command with a machine-dependent +//! target: root → system, otherwise → user. `--system` / `--user` override it. //! //! "Latest" is the highest v* tag on the kernel repo, discovered with -//! `git ls-remote` (no API token, works for the public repo). The header is the -//! HKM banner + current version. +//! `git ls-remote` (no API token, works for the public repo). +//! +//! VERSIONS ARE READ FROM THE KERNEL, NOT FROM THIS BINARY. `banner.version()` +//! is stamped into the launcher at compile time, so comparing it to the latest +//! tag answered "is this BINARY current" while the command went on to replace a +//! KERNEL somewhere else entirely. With two scopes present those two are +//! routinely different numbers. const std = @import("std"); const banner = @import("../lib/banner.zig"); +const composer_version = @import("../lib/composer_version.zig"); +const install_scope = @import("../lib/install_scope.zig"); const kernel = @import("../lib/kernel.zig"); const run_cmd = @import("run.zig"); const util = @import("../lib/util.zig"); @@ -19,6 +46,7 @@ const prompt = @import("../lib/prompt.zig"); const Dir = std.Io.Dir; const Io = std.Io; const EnvMap = std.process.Environ.Map; +const Scope = install_scope.Scope; /// Version handling comes from lib/semver.zig rather than a local copy. /// @@ -77,14 +105,6 @@ fn latestTag(allocator: std.mem.Allocator, io: Io, env: *EnvMap, include_pre: bo return if (best) |b| .{ .tag = b } else .none; } -/// Kernel root (the dir holding composer.json + install.sh) from the resolved -/// CLI path `/bin/hkm`. -fn kernelRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap) ?[]const u8 { - const r = kernel.resolve(allocator, io, env) catch return null; - const bin = std.fs.path.dirname(r.path) orelse return null; // /bin - return std.fs.path.dirname(bin); // -} - /// The file set a release ships, mirroring SRC_PATHS in tools/bundle.sh. /// /// Kept in step with that script deliberately: a local install that copied a @@ -105,22 +125,24 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c // Opt IN to dev / rc releases. Off by default: a pre-release must never // reach someone who did not ask for one. var include_pre = false; - // --user: install into the user's own data dir instead of the system one, - // so nothing about the kernel — including installing plugins into its - // plugins/ — ever needs root. - var user_install = false; // --local builds the checkout before copying it. Without this the tools/ // binaries in zig-out could be older than the source being installed, so // "install my local changes" would ship a launcher that predates them — // the one failure mode a local test install must not have. var build_first = true; + // Which install to act on. Null = decide from privilege (root → system, + // otherwise → user), which is what makes `sudo hkm upgrade` and plain + // `hkm upgrade` two different, predictable commands. + var scope: ?Scope = null; + for (args[1..]) |a| { if (std.mem.eql(u8, a, "--check") or std.mem.eql(u8, a, "-c")) check_only = true; if (std.mem.eql(u8, a, "--local") or std.mem.eql(u8, a, "-l")) from_local = true; if (std.mem.eql(u8, a, "--dry-run") or std.mem.eql(u8, a, "-n")) dry_run = true; if (std.mem.eql(u8, a, "--yes") or std.mem.eql(u8, a, "-y")) assume_yes = true; if (std.mem.eql(u8, a, "--pre")) include_pre = true; - if (std.mem.eql(u8, a, "--user") or std.mem.eql(u8, a, "-u")) user_install = true; + if (std.mem.eql(u8, a, "--user") or std.mem.eql(u8, a, "-u")) scope = .user; + if (std.mem.eql(u8, a, "--system") or std.mem.eql(u8, a, "-s")) scope = .system; if (std.mem.eql(u8, a, "--no-build")) build_first = false; if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { printHelp(); @@ -128,11 +150,32 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c } } + const target = scope orelse install_scope.defaultScope(env); + banner.print(allocator, io, env); - if (from_local) return localUpgrade(allocator, io, env, dry_run, assume_yes, user_install, build_first); + // A system upgrade that is not root cannot write /opt, and every step after + // this point would fail one at a time with a permission error. Say it once, + // at the top, with the command that works. + if (target == .system and !install_scope.isRoot(env)) { + prompt.warn("a system upgrade needs root — re-run it as: sudo hkm upgrade --system"); + prompt.muted(" (or drop --system to update your own user install, which needs no root)"); + return 1; + } + + if (from_local) return localUpgrade(allocator, io, env, target, dry_run, assume_yes, build_first); - const current = parseVer(banner.version()); + const inst = install_scope.detect(allocator, io, env, target); + if (!inst.resolved) { + prompt.err("cannot locate a user install directory (no HOME and no HKM_PREFIX)."); + prompt.muted(" set one: HKM_PREFIX=/srv/hkm hkm upgrade --user"); + return 1; + } + + prompt.section("Target"); + prompt.item("scope", target.how()); + prompt.item("kernel", inst.root); + prompt.item("installed", if (inst.present) install_scope.versionLabel(inst.version) else "not installed"); prompt.muted("checking for updates…"); const latest = switch (latestTag(allocator, io, env, include_pre)) { @@ -149,78 +192,123 @@ pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []c }, }; const latest_ver = parseVer(latest); - - prompt.item("installed", banner.version()); prompt.item("latest", latest); - switch (current.order(latest_ver)) { + // The version of the KERNEL BEING REPLACED, not of this binary. Those are + // different numbers whenever the launcher on PATH belongs to the other + // scope — which is the state that made upgrades look like no-ops. + const current = if (inst.present and inst.version != null) + parseVer(inst.version.?) + else + Ver{}; // absent or unstamped → treat as older than anything, so it installs + + if (!inst.present) { + prompt.warn("nothing installed in this scope yet — this will be a fresh install."); + } else if (inst.version == null) { + // A `--local` install carries the checkout's composer.json, which is + // deliberately unstamped. There is nothing to compare, so proceed + // rather than refuse. + prompt.warn("the installed kernel carries no version — installing the latest release over it."); + } else switch (current.order(latest_ver)) { .eq => { - prompt.ok("you are on the latest version."); + prompt.ok("this scope is on the latest version."); + try reportOtherScope(allocator, io, env, target, latest_ver); return 0; }, .gt => { - prompt.ok("your version is newer than the latest release (dev build)."); + prompt.ok("this scope is newer than the latest release (dev build)."); + try reportOtherScope(allocator, io, env, target, latest_ver); return 0; }, - .lt => { - prompt.warn("an update is available."); - }, + .lt => prompt.warn("an update is available."), } if (check_only) { - prompt.item("to update", "run: hkm upgrade"); + prompt.item("to update", if (target == .system) "run: sudo hkm upgrade --system" else "run: hkm upgrade"); + try reportOtherScope(allocator, io, env, target, latest_ver); return 0; } - // Perform the update. - const root = kernelRoot(allocator, io, env) orelse { - prompt.err("could not locate the kernel install (set HKM_KERNEL_HOME)."); - return 1; - }; - const git_dir = try std.fs.path.join(allocator, &.{ root, ".git" }); - - if (util.fileExists(io, git_dir)) { - // Git checkout install → pull + re-resolve composer deps. + // A git checkout is updated with git, not by unpacking a release over it. + const git_dir = try std.fs.path.join(allocator, &.{ inst.root, ".git" }); + if (inst.present and util.fileExists(io, git_dir)) { prompt.section("Updating (git)"); - var pull = [_][]const u8{ "git", "-C", root, "pull", "--ff-only", "--tags" }; + var pull = [_][]const u8{ "git", "-C", inst.root, "pull", "--ff-only", "--tags" }; _ = run_cmd.spawnWait(io, env, &pull) catch {}; - const installer = try std.fs.path.join(allocator, &.{ root, "install.sh" }); + const installer = try std.fs.path.join(allocator, &.{ inst.root, "install.sh" }); if (util.fileExists(io, installer)) { var sh = [_][]const u8{ "sh", installer }; _ = run_cmd.spawnWait(io, env, &sh) catch {}; } prompt.ok("kernel updated. Verify with: hkm doctor"); + try reportOtherScope(allocator, io, env, target, latest_ver); return 0; } - // Packaged install: detect OS, download the matching artifact, install it. - return performPackagedUpgrade(allocator, io, env, latest); + const code = try performPackagedUpgrade(allocator, io, env, target, latest); + + // Say what was NOT updated, right after saying what was. This is the exact + // moment the old behaviour misled: the command reported success, and the + // very next `hkm` ran the other scope's launcher at the old version with + // nothing on screen connecting the two. + if (code == 0) try reportOtherScope(allocator, io, env, target, latest_ver); + return code; +} + +/// Mention the OTHER scope when it is also installed and also behind. +/// +/// Without this, "you are on the latest version" is true of the scope that was +/// checked and false of the one the user's PATH actually runs — which is the +/// precise shape of "I upgraded and the version did not change". +fn reportOtherScope(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: Scope, latest: Ver) !void { + const other: Scope = if (target == .system) .user else .system; + const inst = install_scope.detect(allocator, io, env, other); + if (!inst.resolved or !inst.present) return; + + const v = inst.version orelse { + prompt.blank(); + prompt.muted(try std.fmt.allocPrint( + allocator, + "note: a {s} install also exists at {s} (unstamped version).", + .{ other.label(), inst.root }, + )); + return; + }; + + if (parseVer(v).order(latest) != .lt) return; + + prompt.blank(); + prompt.warn(try std.fmt.allocPrint( + allocator, + "the {s} install is still on {s} and was NOT touched.", + .{ other.label(), v }, + )); + prompt.item("kernel", inst.root); + prompt.item("update it", if (other == .system) "sudo hkm upgrade --system" else "hkm upgrade --user"); } -/// Download the release artifact for THIS OS and install it. The binary is built -/// per-OS, so builtin.os.tag / cpu.arch are comptime — only this platform's path -/// is compiled in. -fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, latest: []const u8) !u8 { +/// Download the release artifact for THIS OS + scope and install it. The binary +/// is built per-OS, so builtin.os.tag / cpu.arch are comptime — only this +/// platform's path is compiled in. +fn performPackagedUpgrade( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + target: Scope, + latest: []const u8, +) !u8 { const os = @import("builtin").os.tag; - const arch = @import("builtin").cpu.arch; - const ver = if (latest.len > 0 and (latest[0] == 'v' or latest[0] == 'V')) latest[1..] else latest; // "1.0.1" + const ver = composer_version.normalize(latest); // "1.0.1" + + if (os == .linux) return linuxUpgrade(allocator, io, env, target, latest, ver); const asset: []const u8 = switch (os) { - .linux => try std.fmt.allocPrint(allocator, "hkm-kernel_{s}_amd64.deb", .{ver}), .macos => try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-macos-universal.tar.gz", .{ver}), .windows => try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-windows-x86_64.zip", .{ver}), else => return errUnsupported(), }; - if (os == .linux and arch != .x86_64) { - prompt.err("only an amd64 .deb is published; your architecture has no prebuilt package."); - return 1; - } - const url = try std.fmt.allocPrint( - allocator, - "https://github.com/{s}/releases/download/{s}/{s}", - .{ banner.repo(), latest, asset }, - ); + const url = try assetUrl(allocator, latest, asset); const tmp = try std.fs.path.join(allocator, &.{ "/tmp", asset }); prompt.section("Downloading update"); @@ -233,30 +321,10 @@ fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, la prompt.section("Installing"); switch (os) { - .linux => { - // apt handles the local .deb + its dependencies; needs root. - var argv = [_][]const u8{ "sudo", "apt-get", "install", "-y", tmp }; - const code = run_cmd.spawnWait(io, env, &argv) catch 1; - if (code != 0) { - // Fallback: dpkg then fix deps. Both results are KEPT: with them - // discarded, an upgrade where apt AND dpkg both failed printed - // "updated" and left the old kernel installed — the user then - // debugs a version they believe they are no longer running. - var dpkg = [_][]const u8{ "sudo", "dpkg", "-i", tmp }; - const dpkg_code = run_cmd.spawnWait(io, env, &dpkg) catch 1; - var fix = [_][]const u8{ "sudo", "apt-get", "-f", "install", "-y" }; - const fix_code = run_cmd.spawnWait(io, env, &fix) catch 1; - if (dpkg_code != 0 and fix_code != 0) { - prompt.err("installation FAILED — the previous kernel is still in place."); - prompt.muted(try std.fmt.allocPrint(allocator, " the package is downloaded at {s}", .{tmp})); - prompt.muted(" try it by hand: sudo apt-get install -y "); - return 1; - } - } - }, .macos => { // Replace the kernel resources in place, then re-resolve composer. - const root = kernelRoot(allocator, io, env) orelse "/Applications/HKM.app/Contents/Resources/opt/hkm-kernel"; + const root = (try kernel.resolveHome(allocator, io, env)) orelse + "/Applications/HKM.app/Contents/Resources/opt/hkm-kernel"; const app_root = std.fs.path.dirname(std.fs.path.dirname(std.fs.path.dirname(root) orelse root) orelse root) orelse root; var untar = [_][]const u8{ "tar", "-xzf", tmp, "-C", app_root, "--strip-components=0" }; if ((run_cmd.spawnWait(io, env, &untar) catch 1) != 0) { @@ -283,10 +351,152 @@ fn performPackagedUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, la } prompt.blank(); - prompt.ok("updated. Verify with: hkm doctor"); + prompt.ok("updated. Verify with: hkm version"); return 0; } +/// Linux publishes one artifact per scope; pick the one that matches. +fn linuxUpgrade( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + target: Scope, + tag: []const u8, + ver: []const u8, +) !u8 { + const arch = switch (@import("builtin").cpu.arch) { + .x86_64 => "x86_64", + .aarch64 => "aarch64", + else => "", + }; + + return switch (target) { + // ── user: the portable tarball + its own installer. No root anywhere. ── + .user => blk: { + if (arch.len == 0) { + prompt.err("no user-local tarball is published for this architecture."); + break :blk 1; + } + const asset = try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-linux-{s}.tar.gz", .{ ver, arch }); + const url = try assetUrl(allocator, tag, asset); + const tmp = try std.fs.path.join(allocator, &.{ "/tmp", asset }); + + prompt.section("Downloading update"); + prompt.item("asset", asset); + prompt.item("from", url); + if (!download(io, env, url, tmp)) { + prompt.err("download failed — check your connection and try again."); + break :blk 1; + } + + // The tarball carries the user installer at its top level. Running + // it — rather than reimplementing the copy here — is what keeps the + // upgrade identical to a first install: it preserves the project + // registry, swaps the tree atomically, resolves composer against + // THIS machine's PHP, and repairs a stale config pin. + // Unpack into a directory cleared first. A leftover tree from an + // interrupted run could otherwise supply an install.sh from a + // different build than the archive just downloaded. + const work = try std.fmt.allocPrint(allocator, "/tmp/hkm-upgrade-{d}", .{std.Thread.getCurrentId()}); + var rm = [_][]const u8{ "rm", "-rf", work }; + _ = run_cmd.spawnWait(io, env, &rm) catch {}; + Dir.cwd().createDirPath(io, work) catch {}; + var untar = [_][]const u8{ "tar", "-xzf", tmp, "-C", work }; + if ((run_cmd.spawnWait(io, env, &untar) catch 1) != 0) { + prompt.err("could not unpack the release — the previous kernel is still in place."); + prompt.muted(try std.fmt.allocPrint(allocator, " the archive is at {s}", .{tmp})); + break :blk 1; + } + + const installer = try findInstaller(allocator, io, work, ver, arch); + if (installer == null) { + prompt.err("the archive has no install.sh at its top level — cannot continue."); + prompt.muted(try std.fmt.allocPrint(allocator, " unpacked at {s}", .{work})); + break :blk 1; + } + + prompt.section("Installing (user-local, no root)"); + var sh = [_][]const u8{ "sh", installer.?, tmp }; + const code = run_cmd.spawnWait(io, env, &sh) catch 1; + if (code != 0) { + prompt.err("the installer reported a failure — check the output above."); + break :blk 1; + } + + prompt.blank(); + prompt.ok("user install updated. Verify with: hkm version"); + break :blk 0; + }, + + // ── system: the .deb, via apt so its dependencies resolve. ──────────── + .system => blk: { + if (@import("builtin").cpu.arch != .x86_64) { + prompt.err("only an amd64 .deb is published; your architecture has no prebuilt package."); + prompt.muted(" the user-local tarball has no such limit: hkm upgrade --user"); + break :blk 1; + } + const asset = try std.fmt.allocPrint(allocator, "hkm-kernel_{s}_amd64.deb", .{ver}); + const url = try assetUrl(allocator, tag, asset); + const tmp = try std.fs.path.join(allocator, &.{ "/tmp", asset }); + + prompt.section("Downloading update"); + prompt.item("asset", asset); + prompt.item("from", url); + if (!download(io, env, url, tmp)) { + prompt.err("download failed — check your connection and try again."); + break :blk 1; + } + + prompt.section("Installing (system-wide)"); + // Already root by the time we get here (run() refuses otherwise), + // so call apt directly. Prefixing `sudo` unconditionally broke on + // the machines where a system install is most useful — containers + // and CI images run as root and frequently ship no sudo at all. + var argv = [_][]const u8{ "apt-get", "install", "-y", tmp }; + const code = run_cmd.spawnWait(io, env, &argv) catch 1; + if (code != 0) { + // Fallback: dpkg then fix deps. Both results are KEPT: with them + // discarded, an upgrade where apt AND dpkg both failed printed + // "updated" and left the old kernel installed — the user then + // debugs a version they believe they are no longer running. + var dpkg = [_][]const u8{ "dpkg", "-i", tmp }; + const dpkg_code = run_cmd.spawnWait(io, env, &dpkg) catch 1; + var fix = [_][]const u8{ "apt-get", "-f", "install", "-y" }; + const fix_code = run_cmd.spawnWait(io, env, &fix) catch 1; + if (dpkg_code != 0 and fix_code != 0) { + prompt.err("installation FAILED — the previous kernel is still in place."); + prompt.muted(try std.fmt.allocPrint(allocator, " the package is downloaded at {s}", .{tmp})); + prompt.muted(" try it by hand: sudo apt-get install -y "); + break :blk 1; + } + } + + prompt.blank(); + prompt.ok("system install updated. Verify with: hkm version"); + break :blk 0; + }, + }; +} + +/// `/hkm-kernel--linux-/install.sh`, verified to exist. +/// +/// The archive's top-level directory name is fixed by bundle.sh, so it is +/// derived rather than discovered — a directory listing would need a readdir +/// whose API differs across the toolchains this has to build on. +fn findInstaller(allocator: std.mem.Allocator, io: Io, work: []const u8, ver: []const u8, arch: []const u8) !?[]const u8 { + const top = try std.fmt.allocPrint(allocator, "hkm-kernel-{s}-linux-{s}", .{ ver, arch }); + const path = try std.fs.path.join(allocator, &.{ work, top, "install.sh" }); + return if (util.fileExists(io, path)) path else null; +} + +fn assetUrl(allocator: std.mem.Allocator, tag: []const u8, asset: []const u8) ![]const u8 { + return std.fmt.allocPrint( + allocator, + "https://github.com/{s}/releases/download/{s}/{s}", + .{ banner.repo(), tag, asset }, + ); +} + fn errUnsupported() u8 { prompt.err("automatic upgrade is not supported on this platform — download from the releases page."); return 1; @@ -303,48 +513,46 @@ fn download(io: Io, env: *EnvMap, url: []const u8, dest: []const u8) bool { fn printHelp() void { prompt.intro("hkm upgrade"); prompt.section("Usage"); - prompt.item("hkm upgrade", "download and install the latest published release"); - prompt.item("hkm upgrade --check", "report whether an update exists, install nothing"); - prompt.item("hkm upgrade --local", "install THIS checkout over the installed kernel"); + prompt.item("hkm upgrade", "update YOUR install (~/.local) from the latest release — no root"); + prompt.item("sudo hkm upgrade", "update the SYSTEM install (/opt + /usr/bin)"); + prompt.item("hkm upgrade --check", "report what each scope is on, install nothing"); + prompt.item("hkm upgrade --local", "install THIS checkout over an installed kernel"); + prompt.blank(); + prompt.section("Scope"); + prompt.muted("chosen from privilege unless you say otherwise: root → system, else → user"); + prompt.item("--user, -u", "act on ~/.local/lib/hkm-kernel + ~/.local/bin (never needs root)"); + prompt.item("--system, -s", "act on /opt/hkm-kernel + /usr/bin (needs root)"); prompt.blank(); prompt.section("Options"); prompt.item("--local, -l", "source the update from the local checkout instead of GitHub"); prompt.item("--dry-run, -n", "show what --local would copy, write nothing"); prompt.item("--yes, -y", "skip the confirmation prompt"); - prompt.item("--user, -u", "with --local: install into ~/.local/share/hkm/kernel (no sudo, ever)"); - prompt.item("--no-build", "with --local: skip `zig build`, install what is already in tools/zig-out"); + prompt.item("--no-build", "with --local: skip `zig build`, install what is in tools/zig-out"); prompt.item("--pre", "consider pre-releases (dev / rc) when checking for updates"); prompt.item("--check, -c", "check only"); prompt.item("--help, -h", "show this help"); - prompt.outro("--local needs write access to the installed kernel (usually sudo)"); + prompt.outro("`hkm version` shows both scopes and which one your PATH actually runs"); } -/// `hkm upgrade --local` — install the LOCAL checkout over the INSTALLED kernel. +/// `hkm upgrade --local` — install the LOCAL checkout over an INSTALLED kernel. /// /// The normal upgrade path fetches a published release. This one exists for the -/// case that path cannot serve: you have changed the kernel and want the -/// installed copy — the one every project on this machine actually runs — to be -/// that change, without tagging a release first. +/// case that path cannot serve: you have changed the kernel and want an +/// installed copy — the one projects on this machine actually run — to be that +/// change, without tagging a release first. /// -/// It copies the same file set a .deb ships (shipped_paths, mirroring +/// It copies the same file set a release ships (shipped_paths, mirroring /// bundle.sh), so the result behaves like a real install rather than a /// half-synced hybrid. -/// `~/.local/share/hkm/kernel` (or $XDG_DATA_HOME), the user-owned kernel root. -/// -/// The system install lives under /opt and is root-owned, which means every -/// plugin install — they go into the kernel's plugins/ — needs sudo. A kernel -/// inside the user's own data directory removes that entirely, and sits beside -/// the registry hkm already keeps at ~/.local/share/hkm. -fn userKernelRoot(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { - if (env.get("XDG_DATA_HOME")) |x| { - if (x.len > 0) return std.fmt.allocPrint(allocator, "{s}/hkm/kernel", .{util.trimSlash(x)}) catch null; - } - const home = env.get("HOME") orelse return null; - if (home.len == 0) return null; - return std.fmt.allocPrint(allocator, "{s}/.local/share/hkm/kernel", .{util.trimSlash(home)}) catch null; -} - -fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: bool, assume_yes: bool, user_install: bool, build_first: bool) !u8 { +fn localUpgrade( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + target: Scope, + dry_run: bool, + assume_yes: bool, + build_first: bool, +) !u8 { // SOURCE: the checkout this command is being run from or pointed at. const src = (try resolveSource(allocator, io, env)) orelse { prompt.err("no local kernel checkout found."); @@ -353,24 +561,22 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo return 1; }; - // TARGET: the user's own kernel root with --user, otherwise the installed - // one every project on this machine resolves to. - const dest = if (user_install) - userKernelRoot(allocator, env) orelse { - prompt.err("could not determine a user kernel root (no HOME / XDG_DATA_HOME)."); - return 1; - } - else - kernelRoot(allocator, io, env) orelse { - prompt.err("could not locate an installed kernel to update. Is hkm installed (/opt/hkm-kernel)?"); - return 1; - }; + const inst = install_scope.detect(allocator, io, env, target); + if (!inst.resolved) { + prompt.err("cannot locate a user install directory (no HOME and no HKM_PREFIX)."); + prompt.muted(" set one: HKM_PREFIX=/srv/hkm hkm upgrade --local --user"); + return 1; + } + const dest = inst.root; - if (user_install) Dir.cwd().createDirPath(io, dest) catch {}; + // The user scope may not exist yet — creating it is the correct outcome of + // "install my checkout for me", and refusing would leave a non-root user + // with no way to get a kernel at all. + if (target == .user) Dir.cwd().createDirPath(io, dest) catch {}; // Copying a checkout over itself would delete files mid-walk and leave the // only copy of the kernel in an unknown state. - if (std.mem.eql(u8, src, dest)) { + if (std.mem.eql(u8, util.trimSlash(src), util.trimSlash(dest))) { prompt.err("the local checkout IS the installed kernel — there is nothing to copy."); prompt.muted(src); return 1; @@ -381,11 +587,14 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo return 1; } + const version = localVersion(allocator, io, env, src) orelse "unknown"; + prompt.section("Local upgrade"); prompt.item("source", src); prompt.item("target", dest); - prompt.item("version", localVersion(allocator, io, env, src) orelse "unknown"); - prompt.item("installed", installedVersion(allocator, io, dest) orelse banner.version()); + prompt.item("scope", target.how()); + prompt.item("version", version); + prompt.item("installed", if (inst.present) install_scope.versionLabel(inst.version) else "not installed"); if (dry_run) { prompt.blank(); @@ -395,8 +604,8 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo return 0; } - // Overwriting the kernel every project on this machine runs is not - // something to do on a typo. + // Overwriting a kernel that projects on this machine run is not something + // to do on a typo. if (!assume_yes and !prompt.confirm(io, "Overwrite the installed kernel with this checkout?", false)) { prompt.muted("cancelled"); return 1; @@ -500,10 +709,27 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo return 1; } + // Record what was installed, so the result can report its own version. + // + // The checkout's composer.json has NO version field — build.zig only stamps + // a release build, deliberately. Copying it verbatim therefore produced an + // installed kernel that could never say what it was: `hkm version` read + // "unstamped" forever and `hkm upgrade` had nothing to compare, which is a + // large part of why a local install looked like it "did not upgrade". + if (composer_version.writeTo(allocator, io, dest, version)) { + // Report what actually landed in the file. A `git describe` version is + // re-spelled as build metadata to satisfy Composer, so echoing the + // input would name a string the installed kernel does not carry — and + // the next `hkm version` would appear to contradict this line. + prompt.item("stamped", composer_version.ofKernel(allocator, io, dest) orelse version); + } else { + prompt.muted(" could not record the version in the installed composer.json"); + } + // The native launcher is built, not tracked, so it is copied separately — // and only when it exists, since a checkout that has never run `zig build` // has nothing to install. - installLauncher(allocator, io, env, src, needs_root, user_install); + installLauncher(allocator, io, env, src, target, needs_root); // vendor/ is deliberately not shipped, so dependencies are resolved against // the TARGET's PHP rather than whatever the checkout happened to resolve. @@ -545,25 +771,45 @@ fn localUpgrade(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dry_run: boo } } - // A user kernel that nothing points at is inert: resolution would still - // find /opt (or nothing). Record it so every later hkm invocation — and - // therefore every plugin install — uses the root that needs no sudo. - if (user_install) { - userconfig.set(allocator, io, env, "HKM_KERNEL_HOME", dest) catch { - prompt.warn(try std.fmt.allocPrint( - allocator, - "installed, but could not record it. Add this to your shell:\n export HKM_KERNEL_HOME={s}", - .{dest}, - )); - }; - prompt.ok(try std.fmt.allocPrint(allocator, "HKM_KERNEL_HOME set to {s}", .{dest})); - prompt.muted("plugins now install there — no sudo."); - } + try clearStalePin(allocator, io, env, dest); - prompt.outro("Installed kernel updated from the local checkout. Verify with: hkm doctor"); + prompt.outro("Installed kernel updated from the local checkout. Verify with: hkm version"); return 0; } +/// Remove a config.env HKM_KERNEL_HOME pin that this install makes redundant. +/// +/// A user install at ~/.local/lib/hkm-kernel is self-located by +/// ~/.local/bin/hkm, so no pin is needed to reach it. Writing one anyway is what +/// created the original fault: config.env is read by BOTH launchers, so the pin +/// a user-level install left behind also redirected /usr/bin/hkm to the user's +/// kernel. Deleting it hands resolution back to self-location, where each +/// launcher finds its own install. +/// +/// A pin pointing somewhere ELSE is left alone — that is an operator's +/// deliberate choice about a custom layout, and silently discarding it would be +/// its own surprise. It is reported instead. +fn clearStalePin(allocator: std.mem.Allocator, io: Io, env: *EnvMap, dest: []const u8) !void { + const pinned = (userconfig.get(allocator, io, env, "HKM_KERNEL_HOME") catch null) orelse return; + const p = util.trimSlash(std.mem.trim(u8, pinned, " \t\r\n")); + if (p.len == 0) return; + + if (std.mem.eql(u8, p, util.trimSlash(dest))) { + if (userconfig.unset(allocator, io, env, "HKM_KERNEL_HOME") catch false) { + prompt.muted(" removed the now-redundant HKM_KERNEL_HOME pin (the launcher self-locates)"); + } + return; + } + + prompt.warn(try std.fmt.allocPrint( + allocator, + "config.env still pins HKM_KERNEL_HOME={s}", + .{p}, + )); + prompt.muted(" that is only a fallback now, but it will be used if a launcher cannot self-locate."); + prompt.muted(" clear it with: hkm-config unset HKM_KERNEL_HOME"); +} + /// The checkout to install FROM. /// /// Order: HKM_DEV_HOME, then the working directory, then the launcher's own @@ -625,24 +871,6 @@ fn buildCheckout(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []cons prompt.ok(std.fmt.allocPrint(allocator, "built {s}", .{version}) catch "built"); } -/// The TARGET's version, read from the composer.json that ships with it. -/// -/// Not banner.version(): that is the version THIS BINARY was stamped with, and -/// the binary being run is usually the local dev build — so the "installed" -/// line would report the source's version on both sides and always look like a -/// no-op. The two differ exactly when this command is worth running. -fn installedVersion(allocator: std.mem.Allocator, io: Io, dest: []const u8) ?[]const u8 { - const path = std.fs.path.join(allocator, &.{ dest, "composer.json" }) catch return null; - const body = Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null; - - const parsed = std.json.parseFromSliceLeaky(std.json.Value, allocator, body, .{}) catch return null; - if (parsed != .object) return null; - const v = parsed.object.get("version") orelse return null; - if (v != .string or v.string.len == 0) return null; - - return v.string; -} - /// `git describe` in the checkout, so the source's real version is reported /// rather than the version this BINARY was stamped with — they differ exactly /// when a local upgrade is worth doing. @@ -715,39 +943,45 @@ fn warnUntracked(allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []cons } } -/// Install the freshly built native launcher next to the one in use. +/// Install the freshly built native launcher into the target scope's bin dir. +/// +/// The bin dir follows the SCOPE, not the kernel path: a user install's +/// launcher belongs beside its kernel in ~/.local/bin, and writing it to +/// /usr/bin would both need root and overwrite the other install's binary — the +/// two-installs-one-file collision this whole change is about. fn installLauncher( allocator: std.mem.Allocator, io: Io, env: *EnvMap, src: []const u8, + target: Scope, needs_root: bool, - user_install: bool, ) void { const built = std.fs.path.join(allocator, &.{ src, "tools", "zig-out", "bin", "hkm" }) catch return; if (!util.fileExists(io, built)) { - prompt.muted("no built launcher in tools/zig-out — run `zig build` there to update /usr/bin/hkm too."); + prompt.muted("no built launcher in tools/zig-out — run `zig build` there to update the hkm binary too."); return; } + const bin_dir = switch (target) { + .system => install_scope.system_bin_dir, + .user => install_scope.userBinDir(allocator, env) orelse { + prompt.warn("could not determine a user bin directory (no HOME) — launcher not installed."); + return; + }, + }; + if (target == .user) Dir.cwd().createDirPath(io, bin_dir) catch {}; + const targets = [_][]const u8{ "hkm", "hkm-config" }; var failed: usize = 0; var installed_any = false; for (targets) |name| { const from = std.fs.path.join(allocator, &.{ src, "tools", "zig-out", "bin", name }) catch continue; if (!util.fileExists(io, from)) continue; - // A --user install must not write to /usr/bin: that needs root, which - // is the whole thing --user exists to avoid. ~/.local/bin is the - // conventional user-level bin dir and is already on PATH here. - const to = if (user_install) blk: { - const home = env.get("HOME") orelse continue; - const dir = std.fmt.allocPrint(allocator, "{s}/.local/bin", .{util.trimSlash(home)}) catch continue; - Dir.cwd().createDirPath(io, dir) catch {}; - break :blk std.fmt.allocPrint(allocator, "{s}/{s}", .{ dir, name }) catch continue; - } else std.fmt.allocPrint(allocator, "/usr/bin/{s}", .{name}) catch continue; + const to = std.fs.path.join(allocator, &.{ bin_dir, name }) catch continue; var copied = true; - if (needs_root and !user_install) { + if (needs_root and target == .system) { var cp = [_][]const u8{ "sudo", "cp", "-f", from, to }; const code = run_cmd.spawnWait(io, env, &cp) catch blk: { break :blk @as(u8, 1); @@ -801,5 +1035,50 @@ fn installLauncher( } if (failed > 0) return; - if (installed_any) prompt.ok("native launcher updated"); + if (installed_any) { + prompt.ok(std.fmt.allocPrint(allocator, "native launcher updated in {s}", .{bin_dir}) catch "native launcher updated"); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "a release artifact URL is built for the requested tag" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const url = try assetUrl(a, "v1.3.1", "hkm-kernel-1.3.1-linux-x86_64.tar.gz"); + try std.testing.expect(std.mem.endsWith(u8, url, "/releases/download/v1.3.1/hkm-kernel-1.3.1-linux-x86_64.tar.gz")); +} + +test "asset names drop the tag's leading v but the URL path keeps it" { + // The tag is "v1.3.1" and every artifact is named "1.3.1" — mixing the two + // up yields a 404 that reads as "download failed, check your connection". + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const ver = composer_version.normalize("v1.3.1"); + try std.testing.expectEqualStrings("1.3.1", ver); + + const asset = try std.fmt.allocPrint(a, "hkm-kernel-{s}-linux-x86_64.tar.gz", .{ver}); + const url = try assetUrl(a, "v1.3.1", asset); + try std.testing.expect(std.mem.indexOf(u8, url, "/v1.3.1/") != null); + try std.testing.expect(std.mem.indexOf(u8, url, "hkm-kernel-1.3.1-linux") != null); +} + +test "an unstamped install compares as older than any release" { + // A --local install has no version in composer.json. Treating that as + // "equal" would make `hkm upgrade` refuse to replace it forever, which is + // the state a user reads as "upgrading does not change the version". + const latest = parseVer("v1.3.1"); + const unstamped = Ver{}; + try std.testing.expectEqual(std.math.Order.lt, unstamped.order(latest)); +} + +test "a pre-release sorts below its release so a dev tag is opt-in" { + try std.testing.expectEqual(std.math.Order.lt, parseVer("1.4.0-dev").order(parseVer("1.4.0"))); + try std.testing.expectEqual(std.math.Order.gt, parseVer("1.4.0").order(parseVer("1.3.1"))); } diff --git a/tools/src/commands/version.zig b/tools/src/commands/version.zig new file mode 100644 index 0000000..a6c38b8 --- /dev/null +++ b/tools/src/commands/version.zig @@ -0,0 +1,252 @@ +//! `hkm version` — the banner, plus WHICH kernel each install scope holds. +//! +//! The old version command printed one number: `build_info.version`, stamped +//! into the launcher binary at compile time. On a machine with a single install +//! that is the right answer. On a machine with two — a .deb under /opt and a +//! user install under ~/.local, which is an ordinary state — it answers a +//! question nobody asked, and produces exactly the confusion that makes an +//! upgrade look like it did nothing: +//! +//! $ hkm --version → 0.0.0-dev (a stale ~/.local/bin launcher) +//! $ /usr/bin/hkm --version → 1.3.1 (the .deb, first on nobody's PATH) +//! $ sudo hkm upgrade → updates /opt … and the number never moves +//! +//! Three separate versions are in play and they can all differ: +//! +//! • the LAUNCHER binary's stamp — what `--version` reports; +//! • the KERNEL on disk, from its composer.json — what actually runs; +//! • and one of each, per scope. +//! +//! So this prints all of them, marks which kernel this invocation resolves, and +//! says why. `hkm --version` keeps its single-line, script-friendly output. + +const std = @import("std"); +const banner = @import("../lib/banner.zig"); +const install_scope = @import("../lib/install_scope.zig"); +const kernel = @import("../lib/kernel.zig"); +const prompt = @import("../lib/prompt.zig"); +const util = @import("../lib/util.zig"); + +const Io = std.Io; +const EnvMap = std.process.Environ.Map; +const Scope = install_scope.Scope; + +pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []const u8) !u8 { + for (args[1..]) |a| { + if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { + printHelp(); + return 0; + } + } + + banner.print(allocator, io, env); + + const active = try kernel.resolveHomeDetailed(allocator, io, env); + const self_exe = std.process.executableDirPathAlloc(io, allocator) catch null; + + // ── Installs ──────────────────────────────────────────────────────────── + prompt.section("Installs"); + + var rows: std.ArrayList([]const []const u8) = .empty; + var any_present = false; + + for ([_]Scope{ .system, .user }) |scope| { + const inst = install_scope.detect(allocator, io, env, scope); + if (inst.present) any_present = true; + + const marker: []const u8 = blk: { + const root = active.root orelse break :blk " "; + break :blk if (std.mem.eql(u8, util.trimSlash(root), util.trimSlash(inst.root))) "→" else " "; + }; + + const kernel_ver: []const u8 = if (inst.present) + install_scope.versionLabel(inst.version) + else + "not installed"; + + try rows.append(allocator, try allocator.dupe([]const u8, &.{ + marker, + scope.label(), + inst.root, + kernel_ver, + try launcherCell(allocator, io, env, inst, self_exe), + })); + } + + prompt.table( + allocator, + &.{ "", "scope", "kernel", "kernel version", "launcher" }, + rows.items, + ); + + if (!any_present) { + prompt.blank(); + prompt.warn("no kernel is installed in either scope."); + prompt.muted(" user-local (no root): hkm upgrade --user"); + prompt.muted(" system-wide: sudo hkm upgrade --system"); + } + + // ── Active ────────────────────────────────────────────────────────────── + prompt.section("Active"); + if (active.root) |root| { + prompt.item("kernel", root); + prompt.item("resolved via", kernel.sourceLabel(active.source)); + if (install_scope.scopeOf(allocator, env, root)) |s| { + prompt.item("scope", s.label()); + } else { + // A dev checkout or a custom prefix. Worth naming so nobody reads + // the table above and concludes the CLI is running one of those two. + prompt.item("scope", "neither — a checkout or a custom prefix"); + } + } else { + prompt.item("kernel", "NONE FOUND"); + } + prompt.item("this launcher", banner.version()); + if (self_exe) |d| prompt.item("launcher path", try std.fs.path.join(allocator, &.{ d, install_scope.launcher_name })); + + // ── Anything that will mislead the reader later ───────────────────────── + try warnings(allocator, io, env, active, self_exe); + + prompt.outro("upgrade this scope with: hkm upgrade (sudo hkm upgrade for system)"); + return 0; +} + +/// The launcher column: its path and the version IT reports. +/// +/// The version is obtained by running ` --version`, not read off +/// disk, because it is compiled into the binary and there is no other way to +/// see it. That is the point of the column: a launcher whose stamp differs from +/// its kernel's version is the single most common reason an upgrade "did +/// nothing", and it is invisible from any file on disk. +fn launcherCell( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + inst: install_scope.Install, + self_exe: ?[]const u8, +) ![]const u8 { + const exe = inst.launcher orelse return "absent"; + + // Never spawn ourselves: we already know this binary's version, and running + // it would be a pointless subprocess on the hot path of a trivial command. + if (self_exe) |d| { + const own = try std.fs.path.join(allocator, &.{ d, install_scope.launcher_name }); + if (std.mem.eql(u8, own, exe)) { + return std.fmt.allocPrint(allocator, "{s} ({s}, this one)", .{ exe, banner.version() }); + } + } + + const v = launcherVersion(allocator, io, env, exe) orelse return exe; + return std.fmt.allocPrint(allocator, "{s} ({s})", .{ exe, v }); +} + +/// Ask a launcher binary what version it was built as. +/// +/// `hkm --version` prints "hkm (HKM Kernel) " to stdout; take the last +/// whitespace-separated token. Null on any failure — an unreadable version is +/// never a reason to fail the command that reports it. +fn launcherVersion(allocator: std.mem.Allocator, io: Io, env: *EnvMap, exe: []const u8) ?[]const u8 { + const res = std.process.run(allocator, io, .{ + .argv = &.{ exe, "--version" }, + .environ_map = env, + }) catch return null; + switch (res.term) { + .exited => |c| if (c != 0) return null, + else => return null, + } + const line = std.mem.trim(u8, res.stdout, " \t\r\n"); + if (line.len == 0) return null; + const last = std.mem.lastIndexOfScalar(u8, line, ' ') orelse return line; + const v = line[last + 1 ..]; + return if (v.len == 0) null else v; +} + +/// The states that make a later "my upgrade did nothing" report inevitable. +fn warnings( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + active: kernel.ResolvedHome, + self_exe: ?[]const u8, +) !void { + var said = false; + const note = struct { + fn head(flag: *bool) void { + if (flag.*) return; + prompt.section("Worth knowing"); + flag.* = true; + } + }; + + // 1. A different `hkm` earlier on PATH than this one. The upgrade you run + // and the binary your next command uses are then two different installs. + if (findOnPath(allocator, io, env, install_scope.launcher_name)) |first| { + if (self_exe) |d| { + const own = try std.fs.path.join(allocator, &.{ d, install_scope.launcher_name }); + if (!std.mem.eql(u8, own, first)) { + note.head(&said); + prompt.warn("another hkm comes first on your PATH — that is the one a bare `hkm` runs."); + prompt.item("first on PATH", first); + prompt.item("this binary", own); + } + } + } + + // 2. A user install still at the pre-1.4 location. It is a second kernel on + // disk that only a config pin can reach, and it is what the pin usually + // points at on a machine that hit this bug. + const user = install_scope.detect(allocator, io, env, .user); + if (user.legacy_root) |legacy| { + note.head(&said); + prompt.warn("a user kernel exists at the old location and is no longer updated."); + prompt.item("legacy", legacy); + prompt.item("current", user.root); + prompt.item("migrate", "hkm upgrade --user (then delete the legacy directory)"); + } + + // 3. Resolution falling back to a config pin. Legitimate for a custom + // prefix, and the fingerprint of a stale pin otherwise. + if (active.source == .kernel_home_config) { + note.head(&said); + prompt.warn("the active kernel comes from a config.env pin, not from this launcher's own install."); + prompt.item("clear it", "hkm-config unset HKM_KERNEL_HOME"); + } + + // 4. A resolved kernel with no dependencies cannot run anything, and the + // version above would otherwise look perfectly healthy. + if (active.root) |root| { + const autoload = try std.fs.path.join(allocator, &.{ root, "vendor", "autoload.php" }); + if (!util.fileExists(io, autoload)) { + note.head(&said); + prompt.warn("the active kernel has no vendor/ — it cannot boot."); + prompt.item("fix", try std.fmt.allocPrint(allocator, "cd {s} && ./install.sh", .{root})); + } + } +} + +/// First match for `name` on PATH — the one a bare command actually runs. +fn findOnPath(allocator: std.mem.Allocator, io: Io, env: *EnvMap, name: []const u8) ?[]const u8 { + const path = env.get("PATH") orelse return null; + var it = std.mem.splitScalar(u8, path, ':'); + while (it.next()) |dir| { + if (dir.len == 0) continue; + const cand = std.fs.path.join(allocator, &.{ dir, name }) catch continue; + if (util.fileExists(io, cand)) return cand; + } + return null; +} + +fn printHelp() void { + prompt.intro("hkm version"); + prompt.section("Usage"); + prompt.item("hkm version", "banner + the kernel version in each install scope"); + prompt.item("hkm --version", "one line, for scripts (this launcher's version only)"); + prompt.blank(); + prompt.section("What the columns mean"); + prompt.item("kernel version", "from /composer.json — the code that actually runs"); + prompt.item("launcher", "the hkm binary for that scope, and the version it was built as"); + // Spelled out rather than printed as the bare glyph: prompt.item pads keys + // by byte length, and a 3-byte arrow would misalign the whole block. + prompt.item("arrow marker", "the install this invocation resolves"); + prompt.outro("a launcher and kernel that disagree is why an upgrade can look like a no-op"); +} diff --git a/tools/src/config.zig b/tools/src/config.zig index b977617..5cc2e05 100644 --- a/tools/src/config.zig +++ b/tools/src/config.zig @@ -7,9 +7,22 @@ //! hkm-config set-kernel-home

# pin HKM_KERNEL_HOME //! hkm-config set-autoload

# pin HKM_GLOBAL_AUTOLOAD (vendor/autoload.php) //! hkm-config set-dev-home

# pin HKM_DEV_HOME (dev checkout used by --dev) +//! hkm-config unset # remove a key (e.g. a stale HKM_KERNEL_HOME) //! //! "check" resolves the kernel (env → relative to this binary → /opt/hkm-kernel) -//! and, if the config file is missing or stale, writes HKM_KERNEL_HOME for you. +//! and fills in what is missing. +//! +//! WHY IT NO LONGER PINS HKM_KERNEL_HOME UNCONDITIONALLY +//! ----------------------------------------------------- +//! This file is read by EVERY hkm launcher on the machine, and a machine can +//! hold two installs (the .deb's /opt and a user's ~/.local — see +//! lib/install_scope.zig). Writing HKM_KERNEL_HOME here on behalf of whichever +//! install ran `check` last therefore redirected the OTHER install's kernel +//! too: /usr/bin/hkm reported version 1.3.1 while running a kernel out of the +//! user's home. So the pin is now written only when it is actually needed — +//! when the launcher cannot find its kernel by self-locating relative to its +//! own binary. For the standard layouts (both installers produce one) it is +//! left absent, and each launcher resolves its own install independently. const std = @import("std"); const kernel = @import("lib/kernel.zig"); @@ -76,6 +89,20 @@ pub fn main(init: std.process.Init.Minimal) !void { prompt.ok("HKM_DEV_HOME saved. Use `hkm --dev` to target it."); return; } + if (std.mem.eql(u8, action, "unset") or std.mem.eql(u8, action, "clear")) { + if (args.len < 3) return usage(); + const removed = userconfig.unset(allocator, io, &env, args[2]) catch |e| { + prompt.err(@errorName(e)); + std.process.exit(1); + }; + if (removed) { + prompt.ok(try std.fmt.allocPrint(allocator, "{s} removed.", .{args[2]})); + prompt.muted("verify what the launcher resolves now with: hkm version"); + } else { + prompt.muted(try std.fmt.allocPrint(allocator, "{s} was not set — nothing to do.", .{args[2]})); + } + return; + } if (std.mem.eql(u8, action, "check") or std.mem.eql(u8, action, "configure")) { std.process.exit(try runCheck(allocator, io, &env)); } @@ -85,11 +112,12 @@ pub fn main(init: std.process.Init.Minimal) !void { fn usage() void { prompt.section("hkm-config"); - prompt.item("hkm-config", "check config; auto-configure if incomplete"); + prompt.item("hkm-config", "check config; fill in what is missing"); prompt.item("hkm-config print", "show the config file path + contents"); - prompt.item("hkm-config set-kernel-home

", "pin the kernel root"); + prompt.item("hkm-config set-kernel-home

", "pin the kernel root (only needed for a custom layout)"); prompt.item("hkm-config set-autoload

", "pin vendor/autoload.php"); prompt.item("hkm-config set-dev-home

", "pin the development kernel checkout used by --dev"); + prompt.item("hkm-config unset ", "remove a key — e.g. a stale HKM_KERNEL_HOME"); } fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { @@ -104,13 +132,15 @@ fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { prompt.item("exists", if (util.fileExists(io, cfg)) "yes" else "no (will create)"); // 1. Locate the kernel. - const home = (try kernel.resolveHome(allocator, io, env)) orelse { + const resolved = try kernel.resolveHomeDetailed(allocator, io, env); + const home = resolved.root orelse { prompt.blank(); prompt.err("no kernel found."); prompt.item("fix", "install the hkm-kernel package, or: hkm-config set-kernel-home "); return 1; }; prompt.item("kernel home", home); + prompt.item("resolved via", kernel.sourceLabel(resolved.source)); // 2. Check kernel pieces. const autoload = try std.fs.path.join(allocator, &.{ home, "vendor", "autoload.php" }); @@ -120,10 +150,35 @@ fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { prompt.item("vendor/autoload.php", if (have_vendor) "present" else "MISSING"); prompt.item("projects registry", if (have_registry) "present" else "absent (no projects registered yet)"); - // 3. Ensure HKM_KERNEL_HOME is persisted and current. + // 3. Persist HKM_KERNEL_HOME only when the launcher genuinely needs it. + // + // Self-location is per-install and cannot be affected by the other + // scope; a pin in this file is shared by every launcher on the machine. + // So a pin is written only when self-location failed — and an existing + // one that has become redundant is REMOVED, because leaving it is what + // let a user install silently redirect the system launcher's kernel. const saved = try userconfig.get(allocator, io, env, "HKM_KERNEL_HOME"); + const self_locating = resolved.source == .self_located or resolved.source == .default; var wrote = false; - if (saved == null or !std.mem.eql(u8, saved.?, home)) { + + if (self_locating) { + if (saved != null) { + if (std.mem.eql(u8, util.trimSlash(saved.?), util.trimSlash(home))) { + if (try userconfig.unset(allocator, io, env, "HKM_KERNEL_HOME")) { + prompt.item("HKM_KERNEL_HOME", "removed — redundant, the launcher self-locates"); + wrote = true; + } + } else { + // Points elsewhere: an operator's deliberate choice, or a stale + // pin from another install. Not ours to delete silently, but it + // must not be mistaken for the kernel resolved above. + prompt.warn("config.env pins HKM_KERNEL_HOME at a different path."); + prompt.item("pinned", saved.?); + prompt.item("in use", home); + prompt.item("clear it", "hkm-config unset HKM_KERNEL_HOME"); + } + } + } else if (saved == null or !std.mem.eql(u8, saved.?, home)) { try userconfig.set(allocator, io, env, "HKM_KERNEL_HOME", home); wrote = true; } @@ -154,11 +209,12 @@ fn runCheck(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !u8 { } if (wrote) { - prompt.ok("configuration written — HKM_KERNEL_HOME + HKM_USERDATA_DIR pinned."); + prompt.ok("configuration written."); } else { prompt.ok("configuration is complete."); } prompt.muted("verify the runtime with: hkm doctor"); + prompt.muted("see every install on this machine with: hkm version"); return 0; } diff --git a/tools/src/lib/composer_version.zig b/tools/src/lib/composer_version.zig new file mode 100644 index 0000000..8678ac5 --- /dev/null +++ b/tools/src/lib/composer_version.zig @@ -0,0 +1,576 @@ +//! Read and write the `"version"` field of a composer.json. +//! +//! Extracted from src/stamp.zig so that BOTH the build-time stamper and +//! `hkm upgrade` / `hkm version` share one implementation. They must: the +//! stamper writes the field, and the CLI reads it back as the only version +//! marker an installed kernel has. Two copies of the parsing rules would +//! eventually disagree about what counts as a version, and the visible symptom +//! would be an upgrade that reports the wrong number. +//! +//! WHY THE FIELD EXISTS AT ALL (IT IS NORMALLY A LIABILITY) +//! ------------------------------------------------------- +//! A hard-coded "version" in composer.json usually does more harm than good: +//! Composer derives a package's version from its git tags, and a literal field +//! OVERRIDES that. Once the two can disagree, they eventually do — someone tags +//! v1.2.0 and forgets the field, and every consumer resolves the stale number +//! with no error anywhere. This repository has already been bitten by it once +//! (phpshots/bind-it pinned "0.1.3" and its real tags were ignored). +//! +//! It earns its place here for one reason: the native distribution ships +//! WITHOUT a .git directory. A .deb or a tarball has no tags to derive from, so +//! the field is the only version marker the installed kernel has — which is +//! exactly what `hkm version` reports per install scope. + +const std = @import("std"); + +const Io = std.Io; + +// --------------------------------------------------------------------------- +// Reading +// --------------------------------------------------------------------------- + +/// The value of the top-level "version" key, or null when the file has none. +/// +/// Textual rather than a JSON parse for the same reason `stamp` is: this is +/// called on files written by hand and by the stamper, and the caller only ever +/// wants one scalar. A parse would allocate the whole document to answer it. +pub fn parse(source: []const u8) ?[]const u8 { + const span = findVersionValue(source) orelse return null; + const v = source[span.start..span.end]; + return if (v.len == 0) null else v; +} + +/// The version of the kernel installed at `root`, read from `/composer.json`. +/// +/// Null when the root has no composer.json (not an install), or when it has one +/// with no version — which is the normal state of a GIT CHECKOUT, since +/// build.zig deliberately only stamps a release build. +pub fn ofKernel(allocator: std.mem.Allocator, io: Io, root: []const u8) ?[]const u8 { + const path = std.fs.path.join(allocator, &.{ root, "composer.json" }) catch return null; + const body = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch return null; + const v = parse(body) orelse return null; + return allocator.dupe(u8, v) catch null; +} + +// --------------------------------------------------------------------------- +// Writing +// --------------------------------------------------------------------------- + +/// Strip surrounding whitespace and ONE leading `v`, the form Composer wants. +/// +/// One 'v', from the FRONT only. A `trim(..., "v")` would strip the cutset from +/// both ends, so any version ENDING in 'v' lost it: "1.1.0-dev" became +/// "1.1.0-de", which then failed validation and silently skipped stamping. +pub fn normalize(raw: []const u8) []const u8 { + var v = std.mem.trim(u8, raw, " \t\r\n"); + if (v.len > 0 and (v[0] == 'v' or v[0] == 'V')) v = v[1..]; + return v; +} + +/// Rewrite a `git describe` version as semver BUILD METADATA. +/// +/// 1.3.1-2-g34abb2c → 1.3.1+2.g34abb2c +/// +/// Composer rejects the first (its `-` suffix is a stability tag, and "2" is +/// not one) and accepts the second. The two carry identical information and, +/// crucially, identical PRECEDENCE: semver §10 excludes build metadata from +/// ordering, and lib/semver.zig drops everything after `+` — which is exactly +/// what `parseDescribed` already does with the `-2-g34abb2c` form. So this is a +/// change of spelling, not of meaning. +/// +/// Null for anything that is not a describe version; the caller must not invent +/// a spelling for a version it does not recognise. +pub fn describeToComposer(allocator: std.mem.Allocator, version: []const u8) ?[]const u8 { + const v = normalize(version); + if (!isDescribeVersion(v)) return null; + + // Split at the '-' that begins the "-g" trailer: the second + // '-' from the end, since isDescribeVersion has already established both. + const g = std.mem.lastIndexOfScalar(u8, v, '-') orelse return null; + const d = std.mem.lastIndexOfScalar(u8, v[0..g], '-') orelse return null; + + const base = v[0..d]; // "1.3.1" + const commits = v[d + 1 .. g]; // "2" + const sha = v[g + 1 ..]; // "g34abb2c" + + const out = std.fmt.allocPrint(allocator, "{s}+{s}.{s}", .{ base, commits, sha }) catch return null; + return if (composerValid(out)) out else null; +} + +/// Write `version` into `

/composer.json`, best effort. +/// +/// Returns true only when the file now carries a version. Used after a +/// `--local` install: the checkout's composer.json has NO version field (only a +/// release build is stamped, deliberately), so without this the freshly +/// installed kernel is permanently unable to report what it is — `hkm version` +/// reads "unstamped" forever and `hkm upgrade` has nothing to compare, which is +/// a large part of why a local install looked like it never upgraded. +/// +/// A `git describe` version — the shape EVERY build between releases has — is +/// re-spelled as build metadata rather than dropped. That does not contradict +/// the release stamper's rule of "the exact tag or nothing": a release must +/// match the tag it claims to be, whereas a build two commits past v1.3.1 +/// corresponds to no tag at all, and recording which commit it is beats +/// recording nothing. +pub fn writeTo(allocator: std.mem.Allocator, io: Io, dir: []const u8, version: []const u8) bool { + var v = normalize(version); + if (v.len == 0) return false; + if (!composerValid(v)) { + v = describeToComposer(allocator, v) orelse return false; + } + + const path = std.fs.path.join(allocator, &.{ dir, "composer.json" }) catch return false; + const source = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch return false; + + const updated = stamp(allocator, source, v) catch return false; + if (updated) |out| { + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = out }) catch return false; + return true; + } + // null means "already correct" — which is still the outcome asked for. + return true; +} + +/// Semver build metadata: dot-separated identifiers of [0-9A-Za-z-], each +/// non-empty. Deliberately strict — this string is written verbatim into JSON. +fn validMetadata(meta: []const u8) bool { + if (meta.len == 0) return false; + + var it = std.mem.splitScalar(u8, meta, '.'); + while (it.next()) |ident| { + if (ident.len == 0) return false; + for (ident) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '-') return false; + } + } + return true; +} + +/// Nothing that would break out of a JSON string, whatever validation decided. +/// composerValid() is the gate; this is the seatbelt, because the cost of being +/// wrong is a composer.json no install can parse. +fn jsonSafe(v: []const u8) bool { + for (v) |c| { + if (c == '"' or c == '\\' or c < 0x20 or c == 0x7f) return false; + } + return true; +} + +/// Does this look like `git describe` output — "--g"? +/// +/// Matched on the trailing "--g" only, so a real pre-release +/// ("1.1.0-beta.1") is not mistaken for one and still gets a warning. +pub fn isDescribeVersion(v: []const u8) bool { + const g = std.mem.lastIndexOfScalar(u8, v, '-') orelse return false; + const sha = v[g + 1 ..]; + if (sha.len < 2 or sha[0] != 'g') return false; + for (sha[1..]) |c| { + if (!std.ascii.isHex(c)) return false; + } + + const head = v[0..g]; + const d = std.mem.lastIndexOfScalar(u8, head, '-') orelse return false; + const count = head[d + 1 ..]; + if (count.len == 0) return false; + for (count) |c| { + if (!std.ascii.isDigit(c)) return false; + } + return true; +} + +/// Whether Composer will accept this as a package version. +/// +/// A deliberately CONSERVATIVE subset of Composer's own pattern: numeric parts, +/// then an optional stability tag, then an optional `-dev`. Anything it is not +/// sure about is rejected, because the failure mode of a false accept (an +/// install that cannot resolve dependencies) is much worse than a false reject +/// (no version field, which is the status quo for a checkout anyway). +pub fn composerValid(v: []const u8) bool { + var s_ = v; + if (s_.len == 0) return false; + if (s_[0] == 'v' or s_[0] == 'V') s_ = s_[1..]; + + // Build metadata is allowed, but it still has to BE metadata. Discarding it + // unchecked let anything through — `composerValid("1.1.0+\"")` returned + // true, and stamp() writes the version raw between JSON quotes, so that one + // input produced an unparseable composer.json. Semver defines metadata as + // dot-separated [0-9A-Za-z-] identifiers; anything else is rejected. + if (std.mem.indexOfScalar(u8, s_, '+')) |i| { + if (!validMetadata(s_[i + 1 ..])) return false; + s_ = s_[0..i]; + } + if (s_.len == 0) return false; + + // 1-4 numeric components separated by '.' or '-'. + var i: usize = 0; + var parts: usize = 0; + while (i < s_.len and parts < 4) { + const start = i; + while (i < s_.len and std.ascii.isDigit(s_[i])) i += 1; + if (i == start) return false; // expected a number + parts += 1; + if (i < s_.len and (s_[i] == '.' or s_[i] == '-')) { + // Only continue the numeric run when a digit follows. + if (i + 1 < s_.len and std.ascii.isDigit(s_[i + 1])) { + i += 1; + continue; + } + } + break; + } + if (parts == 0) return false; + if (i == s_.len) return true; // plain numeric version + + // Optional separator before the stability tag. + if (s_[i] == '.' or s_[i] == '-' or s_[i] == '_') i += 1; + if (i == s_.len) return false; // trailing separator + + const tail = s_[i..]; + + // Bare "dev" is the only form Composer accepts — no counter after it. + if (std.ascii.eqlIgnoreCase(tail, "dev")) return true; + if (std.ascii.eqlIgnoreCase(tail, "x-dev")) return true; + + // stability tag, optionally followed by (.|-)?digits, repeated. + const tags = [_][]const u8{ "stable", "beta", "alpha", "patch", "rc", "pl", "b", "a", "p" }; + for (tags) |tag| { + if (tail.len < tag.len) continue; + if (!std.ascii.eqlIgnoreCase(tail[0..tag.len], tag)) continue; + + var rest = tail[tag.len..]; + while (rest.len > 0) { + if (rest[0] == '.' or rest[0] == '-') rest = rest[1..]; + if (rest.len == 0) return false; // trailing separator + const start = rest.len; + while (rest.len > 0 and std.ascii.isDigit(rest[0])) rest = rest[1..]; + if (rest.len == start) return false; // expected digits + } + return true; + } + + return false; +} + +/// Return the file with `version` applied, or null when it is already correct. +/// +/// The edit is textual rather than a JSON re-serialise so the file keeps its +/// hand-maintained key order and indentation. Rewriting it through a JSON +/// encoder would reorder every key and produce an unreadable diff per release. +pub fn stamp(allocator: std.mem.Allocator, source: []const u8, version: []const u8) !?[]const u8 { + // The version is written raw between JSON quotes below, so refuse outright + // anything that could terminate the string or embed a control character. + if (!jsonSafe(version)) return null; + + if (findVersionValue(source)) |span| { + if (std.mem.eql(u8, source[span.start..span.end], version)) return null; // no-op + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(allocator, source[0..span.start]); + try out.appendSlice(allocator, version); + try out.appendSlice(allocator, source[span.end..]); + return try out.toOwnedSlice(allocator); + } + + // No "version" key: insert one directly after "name", which is where a + // reader looks for it and where composer's own docs put it. + const anchor = std.mem.indexOf(u8, source, "\"name\"") orelse return null; + const line_end = std.mem.indexOfScalarPos(u8, source, anchor, '\n') orelse return null; + + const indent = detectIndent(source, anchor); + + var out: std.ArrayList(u8) = .empty; + try out.appendSlice(allocator, source[0 .. line_end + 1]); + try out.appendSlice(allocator, indent); + try out.appendSlice(allocator, "\"version\": \""); + try out.appendSlice(allocator, version); + try out.appendSlice(allocator, "\",\n"); + try out.appendSlice(allocator, source[line_end + 1 ..]); + return try out.toOwnedSlice(allocator); +} + +const Span = struct { start: usize, end: usize }; + +/// Byte range of the STRING VALUE of a top-level "version" key. +fn findVersionValue(source: []const u8) ?Span { + var search: usize = 0; + while (std.mem.indexOfPos(u8, source, search, "\"version\"")) |key_at| { + search = key_at + 9; + + // Step over whitespace and the colon. + var i = key_at + 9; + while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; + if (i >= source.len or source[i] != ':') continue; + i += 1; + while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; + if (i >= source.len or source[i] != '"') continue; + + const start = i + 1; + const end = std.mem.indexOfScalarPos(u8, source, start, '"') orelse return null; + return .{ .start = start, .end = end }; + } + return null; +} + +/// The leading whitespace of the line containing `pos`, so an inserted key +/// matches the file's existing indentation rather than imposing a new one. +fn detectIndent(source: []const u8, pos: usize) []const u8 { + var line_start = pos; + while (line_start > 0 and source[line_start - 1] != '\n') line_start -= 1; + + var i = line_start; + while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; + return source[line_start..i]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "replaces an existing version value" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "alfacode-team/php-service-platform", + \\ "version": "1.0.0", + \\ "type": "library" + \\} + ; + const out = (try stamp(a, src, "1.0.21")).?; + defer a.free(out); + + try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"1.0.21\"") != null); + try std.testing.expect(std.mem.indexOf(u8, out, "1.0.0") == null); + // Everything else must be untouched. + try std.testing.expect(std.mem.indexOf(u8, out, "\"type\": \"library\"") != null); +} + +test "inserts the key after name when absent, matching indentation" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "alfacode-team/php-service-platform", + \\ "type": "library" + \\} + ; + const out = (try stamp(a, src, "1.0.21")).?; + defer a.free(out); + + try std.testing.expect(std.mem.indexOf(u8, out, " \"version\": \"1.0.21\",\n") != null); + // It must still parse. + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), out, .{}); + try std.testing.expectEqualStrings("1.0.21", parsed.object.get("version").?.string); +} + +test "an already-correct version is a no-op" { + // Returning null keeps the build from rewriting the file (and dirtying the + // working tree) on every single invocation. + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "x/y", + \\ "version": "1.0.21" + \\} + ; + try std.testing.expect((try stamp(a, src, "1.0.21")) == null); +} + +test "does not mistake a nested version for the package's own" { + // "require" blocks are full of version-looking keys; only a top-level + // "version" KEY should ever be rewritten. + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "x/y", + \\ "require": { "php": ">=8.4" } + \\} + ; + const out = (try stamp(a, src, "2.0.0")).?; + defer a.free(out); + + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), out, .{}); + try std.testing.expectEqualStrings("2.0.0", parsed.object.get("version").?.string); + try std.testing.expectEqualStrings(">=8.4", parsed.object.get("require").?.object.get("php").?.string); +} + +test "a leading v is stripped so composer sees a bare version" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "x/y" + \\} + ; + const out = (try stamp(a, src, normalize("v1.0.21"))).?; + defer a.free(out); + try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"v") == null); + try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"1.0.21\"") != null); +} + +test "accepts the versions composer accepts" { + // Verified against `composer validate` before being encoded here. + for ([_][]const u8{ + "1.1.0", "1.0.21", "v1.1.0", "1.1.0-dev", "1.1.0-beta.2", + "1.1.0-RC2", "1.1.0-alpha.2", "1.2.3.4", "1.1.0+meta", + }) |v| { + try std.testing.expect(composerValid(v)); + } +} + +test "rejects the version that broke a real install" { + // "1.1.0-dev.2" was stamped from a git tag and made `composer install` + // abort on every machine that took the update. Composer's dev suffix takes + // no counter. + try std.testing.expect(!composerValid("1.1.0-dev.2")); + try std.testing.expect(!composerValid("1.1.0-dev2")); +} + +test "rejects anything it cannot vouch for" { + for ([_][]const u8{ + "", "v", "abc", "1.1.0-", "1.1.0-nonsense", "1.1.0-beta.", "-1.0.0", + }) |v| { + try std.testing.expect(!composerValid(v)); + } +} + +test "a version ending in 'v' keeps its last character" { + // Regression: the trim used the cutset " \t\r\nv" on BOTH ends, so + // "1.1.0-dev" arrived as "1.1.0-de" and was rejected as invalid — the one + // pre-release form Composer actually accepts. + try std.testing.expectEqualStrings("1.1.0-dev", normalize("v1.1.0-dev")); + try std.testing.expect(composerValid("1.1.0-dev")); + try std.testing.expect(!composerValid("1.1.0-de")); +} + +test "build metadata is validated, not waved through" { + // The bug: metadata was discarded unchecked, so this returned true — and + // stamp() writes the version raw between JSON quotes, producing a + // composer.json no install can parse. + try std.testing.expect(!composerValid("1.1.0+\"")); + try std.testing.expect(!composerValid("1.1.0+a\\b")); + try std.testing.expect(!composerValid("1.1.0+a\nb")); + try std.testing.expect(!composerValid("1.1.0+")); // empty metadata + try std.testing.expect(!composerValid("1.1.0+a..b")); // empty identifier + try std.testing.expect(!composerValid("1.1.0+a b")); + + // …while real metadata still passes. + try std.testing.expect(composerValid("1.1.0+build.1")); + try std.testing.expect(composerValid("1.1.0+20260812")); + try std.testing.expect(composerValid("1.1.0+g29dccfb")); + try std.testing.expect(composerValid("1.1.0-beta.1+exp.sha.5114f85")); +} + +test "stamp refuses a version that could break out of the JSON string" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "acme/pkg", + \\ "type": "library" + \\} + ; + for ([_][]const u8{ "1.0.0+\"", "1.0.0\\", "1.0.0\n", "1.0.0\x7f" }) |bad| { + try std.testing.expect((try stamp(a, src, bad)) == null); + } +} + +test "a stamped composer.json is still parseable JSON" { + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "acme/pkg", + \\ "type": "library" + \\} + ; + const out = (try stamp(a, src, "1.2.0")) orelse return error.ExpectedOutput; + defer a.free(out); + + const parsed = try std.json.parseFromSlice(std.json.Value, a, out, .{}); + defer parsed.deinit(); + try std.testing.expectEqualStrings("1.2.0", parsed.value.object.get("version").?.string); +} + +test "a git describe version is recognised so dev builds stay quiet" { + try std.testing.expect(isDescribeVersion("1.1.0-dev.2-12-g29dccfb")); + try std.testing.expect(isDescribeVersion("1.0.21-138-gbdbbf34")); + + // A real pre-release must NOT be mistaken for one: those are release + // intents, and silently skipping them is how a release ships unstamped. + try std.testing.expect(!isDescribeVersion("1.1.0-beta.1")); + try std.testing.expect(!isDescribeVersion("1.1.0-dev.2")); + try std.testing.expect(!isDescribeVersion("1.1.0")); + try std.testing.expect(!isDescribeVersion("1.1.0-12-gzz")); +} + +test "parse reads back what stamp wrote" { + // The read and the write are two halves of one contract: `hkm version` + // reports what a release build stamped. A change to either that breaks the + // round trip makes every installed kernel report "unknown". + const a = std.testing.allocator; + const src = + \\{ + \\ "name": "acme/pkg" + \\} + ; + const out = (try stamp(a, src, "1.3.1")).?; + defer a.free(out); + try std.testing.expectEqualStrings("1.3.1", parse(out).?); +} + +test "a git describe version is re-spelled as composer-valid build metadata" { + // The version every build between releases carries. Composer rejects the + // "-2-g34abb2c" form, so a --local install used to record nothing at all + // and could never report what it was. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const out = describeToComposer(a, "v1.3.1-2-g34abb2c").?; + try std.testing.expectEqualStrings("1.3.1+2.g34abb2c", out); + try std.testing.expect(composerValid(out)); + + const long = describeToComposer(a, "1.0.21-138-gbdbbf34").?; + try std.testing.expectEqualStrings("1.0.21+138.gbdbbf34", long); + try std.testing.expect(composerValid(long)); +} + +test "the re-spelling preserves precedence exactly" { + // The whole justification: semver excludes build metadata from ordering, + // and lib/semver.zig's parseDescribed already collapses the '-' form to the + // same base version. If these two ever disagreed, `hkm upgrade` would rank a + // local build differently depending on which spelling happened to be on + // disk. + const semver = @import("semver.zig"); + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const described = semver.parseDescribed("1.3.1-2-g34abb2c").?; + const respelled = semver.parseDescribed(describeToComposer(a, "1.3.1-2-g34abb2c").?).?; + try std.testing.expectEqual(std.math.Order.eq, described.order(respelled)); + try std.testing.expectEqual(std.math.Order.eq, respelled.order(semver.Version.parse("1.3.1").?)); +} + +test "describeToComposer invents nothing for a version it does not recognise" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + // A real pre-release is a release intent, not a describe trailer — silently + // rewriting one would make composer.json disagree with the tag it claims. + try std.testing.expect(describeToComposer(a, "1.3.1-beta.1") == null); + try std.testing.expect(describeToComposer(a, "1.3.1") == null); + try std.testing.expect(describeToComposer(a, "garbage") == null); +} + +test "parse returns null for a checkout composer.json with no version" { + // The normal state of this repo: build.zig only stamps a release build, so + // a checkout legitimately has no version and must not report a wrong one. + const src = + \\{ + \\ "name": "alfacode-team/php-service-platform", + \\ "require": { "php": ">=8.4" } + \\} + ; + try std.testing.expect(parse(src) == null); +} diff --git a/tools/src/lib/install_scope.zig b/tools/src/lib/install_scope.zig new file mode 100644 index 0000000..8396360 --- /dev/null +++ b/tools/src/lib/install_scope.zig @@ -0,0 +1,415 @@ +//! The two install SCOPES a machine can hold, and how to find each one. +//! +//! HKM ships two installers with two different footprints, and until this file +//! existed nothing in the CLI modelled that they can both be present: +//! +//! system .deb /opt/hkm-kernel + /usr/bin/{hkm,hkm-config} root +//! user tarball ~/.local/lib/hkm-kernel + ~/.local/bin/{hkm,hkm-config} no root +//! +//! Both are legitimate and they COEXIST — a machine with a system install from +//! an earlier deploy, plus a user install for day-to-day work, is the ordinary +//! state, not a broken one. What was broken was that every part of the CLI +//! spoke of "the installed kernel" as if there were one: +//! +//! • `hkm upgrade` on Linux only ever fetched the .deb and shelled out to +//! sudo apt-get, so a user install could never update itself. PATH then +//! resolved the STALE user launcher first and the upgrade looked like it +//! had done nothing. +//! • `hkm version` printed the launcher's own compile-time stamp and named no +//! kernel at all, so with two installs present it answered a question +//! nobody asked. +//! • One shared `HKM_KERNEL_HOME` pin in ~/.config/hkm/config.env was read by +//! BOTH launchers, so whichever installer ran last silently redirected the +//! other one's kernel. (See lib/kernel.zig for how resolution now stops +//! that.) +//! +//! Every one of those is the same missing distinction. This file supplies it: +//! given the environment, report what is installed in each scope, at what +//! version, and which one this invocation is actually running. +//! +//! The paths here are not free parameters. `system` mirrors the layout +//! tools/bundle.sh writes into the .deb, and `user` mirrors what +//! tools/install.sh writes into $HKM_PREFIX — including the bin/ + lib/ pairing +//! that lets the launcher self-locate its kernel with no env var at all. +//! Changing one side without the other breaks resolution. + +const std = @import("std"); +const composer_version = @import("composer_version.zig"); +const util = @import("util.zig"); + +const Io = std.Io; +const EnvMap = std.process.Environ.Map; + +pub const Scope = enum { + system, + user, + + pub fn label(self: Scope) []const u8 { + return switch (self) { + .system => "system", + .user => "user", + }; + } + + /// How that scope is installed — used in guidance, so it names the command + /// the reader should actually run. + pub fn how(self: Scope) []const u8 { + return switch (self) { + .system => "system-wide (.deb, needs root)", + .user => "user-local (tarball, no root)", + }; + } +}; + +/// The system kernel root. Fixed by the .deb's own layout. +pub const system_root = "/opt/hkm-kernel"; + +/// Where the .deb puts the launcher. +pub const system_bin_dir = "/usr/bin"; + +/// Directories a launcher living in means "this is the system install". +/// +/// Needed because /usr/bin/hkm CANNOT self-locate /opt/hkm-kernel by relative +/// probing — there is no fixed relative path between them — so without this the +/// system launcher fell through to the config-file pin, which is precisely the +/// hijack this module exists to prevent. +pub const system_bin_dirs = [_][]const u8{ "/usr/bin", "/usr/local/bin", "/bin", "/sbin", "/usr/sbin" }; + +/// Is `dir` one of the system bin directories? +pub fn isSystemBinDir(dir: []const u8) bool { + const d = util.trimSlash(dir); + for (system_bin_dirs) |candidate| { + if (std.mem.eql(u8, d, candidate)) return true; + } + return false; +} + +/// The invoking user's home directory. +/// +/// Honours SUDO_USER, because under `sudo hkm …` HOME is root's (/root) while +/// every user-scope path the command needs to REPORT belongs to the person who +/// typed the command. Without this, `sudo hkm version` would claim there is no +/// user install on a machine that has one. +pub fn homeDir(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + if (env.get("SUDO_USER")) |user| { + if (user.len > 0 and !std.mem.eql(u8, user, "root")) { + return std.fmt.allocPrint(allocator, "/home/{s}", .{user}) catch null; + } + } + const home = env.get("HOME") orelse return null; + if (home.len == 0) return null; + return util.trimSlash(home); +} + +/// The user install PREFIX: $HKM_PREFIX, else ~/.local. +/// +/// Same variable tools/install.sh reads, so `--prefix /srv/hkm` and +/// `HKM_PREFIX=/srv/hkm hkm upgrade` land in the same place. +pub fn userPrefix(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + if (env.get("HKM_PREFIX")) |p| { + if (p.len > 0) return util.trimSlash(p); + } + const home = homeDir(allocator, env) orelse return null; + return std.fmt.allocPrint(allocator, "{s}/.local", .{home}) catch null; +} + +/// The user kernel root: `/lib/hkm-kernel`. +/// +/// This path is chosen so `/bin/hkm` self-locates it by probing +/// "/lib/hkm-kernel" (lib/kernel.zig). That is what makes a +/// user install need NO environment variable and NO config pin — and therefore +/// what stops it from having to write a pin that then hijacks the system +/// install. The earlier `--user` target (~/.local/share/hkm/kernel) sat outside +/// every probe, so it could only be reached through a pin; see legacyUserRoot. +pub fn userRoot(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + const prefix = userPrefix(allocator, env) orelse return null; + return std.fmt.allocPrint(allocator, "{s}/lib/hkm-kernel", .{prefix}) catch null; +} + +/// Where a user install puts its launchers: `/bin`. +pub fn userBinDir(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + const prefix = userPrefix(allocator, env) orelse return null; + return std.fmt.allocPrint(allocator, "{s}/bin", .{prefix}) catch null; +} + +/// Where `hkm upgrade --local --user` used to install: the userdata dir. +/// +/// Still probed so a machine that took that path is RECOGNISED rather than +/// reported as having no user install — and so the migration can name it. +/// Nothing writes here any more. +pub fn legacyUserRoot(allocator: std.mem.Allocator, env: *EnvMap) ?[]const u8 { + if (env.get("XDG_DATA_HOME")) |x| { + if (x.len > 0) return std.fmt.allocPrint(allocator, "{s}/hkm/kernel", .{util.trimSlash(x)}) catch null; + } + const home = homeDir(allocator, env) orelse return null; + return std.fmt.allocPrint(allocator, "{s}/.local/share/hkm/kernel", .{home}) catch null; +} + +/// Is this process running with root privileges? +/// +/// This is the switch that makes `sudo hkm upgrade` update the system install +/// and a plain `hkm upgrade` update the user's own. EFFECTIVE uid rather than +/// SUDO_USER, because that is what actually decides whether the write to /opt +/// will succeed — `su -`, a root shell and a container all have no SUDO_USER +/// and are all genuinely root. +/// +/// The syscall is reached per-platform rather than through std.posix, which has +/// no geteuid in the pinned toolchain (0.17.0-dev). Linux gets the raw syscall +/// so a statically linked launcher needs no libc; everything else POSIX goes +/// through the libc symbol. +pub fn isRoot(env: *EnvMap) bool { + _ = env; + return switch (@import("builtin").os.tag) { + .windows => false, + .linux => std.os.linux.geteuid() == 0, + else => std.c.geteuid() == 0, + }; +} + +/// The scope a command should act on when the user named none. +/// +/// Root → system, otherwise → user. Deliberately derived from privilege rather +/// than from what happens to be installed: it makes `sudo hkm upgrade` and +/// `hkm upgrade` two predictable, different commands instead of one command +/// whose target depends on machine state. +pub fn defaultScope(env: *EnvMap) Scope { + return if (isRoot(env)) .system else .user; +} + +/// What is installed in one scope. +pub const Install = struct { + scope: Scope, + /// Could this scope's paths be resolved at all? + /// + /// False only for `.user` with no HOME and no HKM_PREFIX — a cron job or a + /// stripped service environment. It exists so an unresolvable user scope is + /// never quietly represented by the SYSTEM paths: an upgrade that fell back + /// that way would write to /opt on behalf of a command the user ran + /// specifically to avoid touching /opt. + resolved: bool, + /// Kernel root for this scope — always populated, even when absent, so a + /// diagnostic can say WHERE it looked. Meaningless when `resolved` is false. + root: []const u8, + /// Directory the launcher for this scope lives in, when it is resolvable. + bin_dir: ?[]const u8, + /// The kernel root holds a composer.json. + present: bool, + /// `"version"` from that composer.json — null for a checkout-style install + /// that was never stamped. + version: ?[]const u8, + /// Path to the launcher binary, when one exists there. + launcher: ?[]const u8, + /// Dependencies resolved (vendor/autoload.php present). + vendor: bool, + /// A LEGACY user install found at the old ~/.local/share/hkm/kernel path. + /// Only ever set for .user. + legacy_root: ?[]const u8 = null, +}; + +/// Inspect one scope. Never fails: an absent install is a result, not an error. +pub fn detect(allocator: std.mem.Allocator, io: Io, env: *EnvMap, scope: Scope) Install { + const maybe_root: ?[]const u8 = switch (scope) { + .system => system_root, + .user => userRoot(allocator, env), + }; + const bin_dir: ?[]const u8 = switch (scope) { + .system => system_bin_dir, + .user => userBinDir(allocator, env), + }; + + var out = Install{ + .scope = scope, + .resolved = maybe_root != null, + .root = maybe_root orelse "(no HOME — user scope unresolvable)", + .bin_dir = bin_dir, + .present = false, + .version = null, + .launcher = null, + .vendor = false, + }; + const root = maybe_root orelse return out; + + const manifest = std.fs.path.join(allocator, &.{ root, "composer.json" }) catch return out; + out.present = util.fileExists(io, manifest); + if (out.present) { + out.version = composer_version.ofKernel(allocator, io, root); + if (std.fs.path.join(allocator, &.{ root, "vendor", "autoload.php" })) |autoload| { + out.vendor = util.fileExists(io, autoload); + } else |_| {} + } + + if (bin_dir) |dir| { + if (std.fs.path.join(allocator, &.{ dir, launcher_name })) |exe| { + if (util.fileExists(io, exe)) out.launcher = exe; + } else |_| {} + } + + // A user install left at the pre-1.4 path is worth surfacing even when the + // current one is fine — it is a second kernel on disk that a stale pin can + // still point at. + if (scope == .user) { + if (legacyUserRoot(allocator, env)) |legacy| { + if (!std.mem.eql(u8, legacy, root)) { + if (std.fs.path.join(allocator, &.{ legacy, "composer.json" })) |m| { + if (util.fileExists(io, m)) out.legacy_root = legacy; + } else |_| {} + } + } + } + + return out; +} + +/// The launcher's filename on this platform. +pub const launcher_name = if (@import("builtin").os.tag == .windows) "hkm.exe" else "hkm"; + +/// Which scope does a kernel root belong to? Null when it is neither — a dev +/// checkout, or an operator's custom prefix. +pub fn scopeOf(allocator: std.mem.Allocator, env: *EnvMap, root: []const u8) ?Scope { + const r = util.trimSlash(root); + if (std.mem.eql(u8, r, system_root)) return .system; + if (userRoot(allocator, env)) |u| { + if (std.mem.eql(u8, r, util.trimSlash(u))) return .user; + } + if (legacyUserRoot(allocator, env)) |u| { + if (std.mem.eql(u8, r, util.trimSlash(u))) return .user; + } + return null; +} + +/// The version to print for a kernel root: its stamped version, or a marker. +/// +/// "unstamped" rather than "unknown" is deliberate — for a `--local` install +/// from a checkout it is the CORRECT answer, and it points at the reason +/// (nothing stamped it) instead of implying something is broken. +pub fn versionLabel(v: ?[]const u8) []const u8 { + return v orelse "unstamped"; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "the user kernel root is the path the launcher can self-locate" { + // bin/ + lib/ side by side is what lib/kernel.zig probes as + // "/lib/hkm-kernel". If this pairing drifts, a user + // install becomes reachable only through a config pin — and a config pin is + // read by BOTH launchers, which is the hijack this module exists to stop. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/home/tester"); + + try std.testing.expectEqualStrings("/home/tester/.local/lib/hkm-kernel", userRoot(a, &env).?); + try std.testing.expectEqualStrings("/home/tester/.local/bin", userBinDir(a, &env).?); +} + +test "HKM_PREFIX relocates both halves together" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/home/tester"); + try env.put("HKM_PREFIX", "/srv/hkm/"); + + // The trailing slash must not produce "//lib" — install.sh writes the same + // two paths and they have to match byte for byte for scopeOf to work. + try std.testing.expectEqualStrings("/srv/hkm/lib/hkm-kernel", userRoot(a, &env).?); + try std.testing.expectEqualStrings("/srv/hkm/bin", userBinDir(a, &env).?); +} + +test "sudo reports the invoking user's install, not root's" { + // Under `sudo hkm version` HOME is /root. Resolving the user scope from it + // would claim the machine has no user install while one sits in the home + // directory of the person who typed the command. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/root"); + try env.put("SUDO_USER", "tester"); + + try std.testing.expectEqualStrings("/home/tester", homeDir(a, &env).?); + try std.testing.expectEqualStrings("/home/tester/.local/lib/hkm-kernel", userRoot(a, &env).?); +} + +test "SUDO_USER=root is not treated as a different user" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/root"); + try env.put("SUDO_USER", "root"); + + try std.testing.expectEqualStrings("/root", homeDir(a, &env).?); +} + +test "scopeOf recognises both current roots and the legacy user one" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + try env.put("HOME", "/home/tester"); + + try std.testing.expectEqual(Scope.system, scopeOf(a, &env, "/opt/hkm-kernel").?); + try std.testing.expectEqual(Scope.system, scopeOf(a, &env, "/opt/hkm-kernel/").?); + try std.testing.expectEqual(Scope.user, scopeOf(a, &env, "/home/tester/.local/lib/hkm-kernel").?); + // The pre-1.4 --user target still resolves to the user scope, so a machine + // holding one is diagnosed rather than reported as "neither". + try std.testing.expectEqual(Scope.user, scopeOf(a, &env, "/home/tester/.local/share/hkm/kernel").?); + // A dev checkout belongs to no install scope. + try std.testing.expect(scopeOf(a, &env, "/home/tester/Documents/HKMCODE") == null); +} + +test "a launcher in a system bin dir is recognised as the system install" { + // This is what lets /usr/bin/hkm claim /opt/hkm-kernel ahead of a + // config-file pin. Without it the .deb launcher has no self-location at all + // and follows whatever the last user-level installer wrote. + try std.testing.expect(isSystemBinDir("/usr/bin")); + try std.testing.expect(isSystemBinDir("/usr/bin/")); + try std.testing.expect(isSystemBinDir("/usr/local/bin")); + try std.testing.expect(!isSystemBinDir("/home/tester/.local/bin")); + try std.testing.expect(!isSystemBinDir("/opt/hkm-kernel/bin")); +} + +test "an unstamped kernel says so rather than claiming to be unknown" { + try std.testing.expectEqualStrings("1.3.1", versionLabel("1.3.1")); + try std.testing.expectEqualStrings("unstamped", versionLabel(null)); +} + +test "an unresolvable user scope never resolves to the system paths" { + // With no HOME and no HKM_PREFIX there is no user install to speak of. The + // dangerous outcome is not "no result" but a SILENT fallback to /opt: `hkm + // upgrade` would then write system-wide on behalf of a command whose whole + // purpose is to avoid that. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); + defer threaded.deinit(); + + var env = std.process.Environ.Map.init(a); + defer env.deinit(); + + const user = detect(a, threaded.io(), &env, .user); + try std.testing.expect(!user.resolved); + try std.testing.expect(!std.mem.eql(u8, user.root, system_root)); + try std.testing.expect(!user.present); + + // The system scope is always resolvable — its paths are constants. + const system = detect(a, threaded.io(), &env, .system); + try std.testing.expect(system.resolved); + try std.testing.expectEqualStrings(system_root, system.root); +} diff --git a/tools/src/lib/kernel.zig b/tools/src/lib/kernel.zig index 47242bc..362feae 100644 --- a/tools/src/lib/kernel.zig +++ b/tools/src/lib/kernel.zig @@ -1,15 +1,57 @@ //! Kernel location resolution shared by the launcher passthrough (main.zig) and //! `hkm doctor`. Given the environment, returns the path to the kernel's PHP CLI //! (`/bin/hkm`) that the launcher invokes as `php …`. +//! +//! RESOLUTION ORDER, AND WHY A CONFIG PIN NO LONGER WINS +//! ---------------------------------------------------- +//! A machine can hold two installs at once — the .deb's /opt/hkm-kernel and a +//! user's ~/.local/lib/hkm-kernel (see lib/install_scope.zig). Both launchers +//! read the SAME ~/.config/hkm/config.env, and HKM_KERNEL_HOME used to be +//! checked before anything else. So whichever installer wrote that pin last +//! silently redirected the other install too: +//! +//! $ /usr/bin/hkm --version → 1.3.1 (the .deb's launcher) +//! $ /usr/bin/hkm doctor +//! kernel root /home/me/.local/share/hkm/kernel ← the USER's kernel +//! resolved via HKM_KERNEL_HOME override +//! +//! Upgrading either scope then appeared to do nothing, because the version on +//! screen came from a launcher whose kernel belonged to the other install. The +//! order below fixes that by ranking the sources by how specific they are to +//! THIS invocation: +//! +//! 1. HKM_CLI_PATH / HKM_KERNEL_HOME exported in the real environment — +//! this command's explicit instruction, always wins. +//! 2. Self-location relative to this launcher's own executable, which is +//! per-install by construction and cannot be affected by the other one. +//! For a launcher in a system bin dir (/usr/bin) that includes claiming +//! /opt/hkm-kernel, since no relative probe can reach it from there. +//! 3. HKM_KERNEL_HOME from config.env — now a FALLBACK, for installs at a +//! custom path that self-location genuinely cannot find. +//! 4. /opt/hkm-kernel, the last-resort default. +//! +//! The behaviour change is narrow: a config pin still works whenever the +//! launcher cannot self-locate a kernel, which is the case it was added for. It +//! no longer overrides an install that is sitting right next to the binary. const std = @import("std"); +const install_scope = @import("install_scope.zig"); +const userconfig = @import("userconfig.zig"); const util = @import("util.zig"); const Io = std.Io; const EnvMap = std.process.Environ.Map; /// How the kernel CLI path was determined — surfaced by `hkm doctor`. -pub const Source = enum { cli_path_env, kernel_home_env, self_located, default }; +pub const Source = enum { + cli_path_env, + /// HKM_KERNEL_HOME exported in the real environment. + kernel_home_env, + /// HKM_KERNEL_HOME from ~/.config/hkm/config.env (a fallback, not an override). + kernel_home_config, + self_located, + default, +}; pub const Resolved = struct { path: []const u8, @@ -23,40 +65,51 @@ fn envGet(allocator: std.mem.Allocator, map: *EnvMap, key: []const u8) !?[]const return try allocator.dupe(u8, v); } +/// HKM_KERNEL_HOME, split by where it came from. +const Pin = struct { + value: []const u8, + /// From config.env rather than a real export — see the header. + from_config: bool, +}; + +fn kernelHomePin(allocator: std.mem.Allocator, env: *EnvMap) !?Pin { + const raw = (try envGet(allocator, env, "HKM_KERNEL_HOME")) orelse return null; + const v = util.trimSlash(std.mem.trim(u8, raw, " \t\r\n")); + if (v.len == 0) return null; + return .{ .value = v, .from_config = userconfig.isFileSourced(env, "HKM_KERNEL_HOME") }; +} + /// Resolve the kernel PHP CLI path with full provenance (for diagnostics). pub fn resolve(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !Resolved { - // 1. Explicit overrides always win. + // 1. An explicit CLI path is the most specific instruction there is. if (try envGet(allocator, env, "HKM_CLI_PATH")) |v| { return .{ .path = v, .source = .cli_path_env, .exists = util.fileExists(io, v) }; } - if (try envGet(allocator, env, "HKM_KERNEL_HOME")) |home| { - const p = try std.fs.path.join(allocator, &.{ home, "bin", "hkm" }); - return .{ .path = p, .source = .kernel_home_env, .exists = util.fileExists(io, p) }; - } - // 2. Self-locate the kernel RELATIVE to this launcher's own executable, so a - // portable/zip/.app install needs no env var. Candidates cover every - // bundle layout produced by tools/bundle.sh: - // macOS .app: /hkm + ../Resources/opt/hkm-kernel/bin/hkm - // Windows zip: /hkm.exe + hkm-kernel/bin/hkm - // portable: /hkm + ../opt/hkm-kernel/bin/hkm - if (std.process.executableDirPathAlloc(io, allocator)) |dir| { - const rels = [_][]const []const u8{ - &.{ dir, "..", "Resources", "opt", "hkm-kernel", "bin", "hkm" }, - &.{ dir, "hkm-kernel", "bin", "hkm" }, - &.{ dir, "..", "opt", "hkm-kernel", "bin", "hkm" }, - &.{ dir, "..", "lib", "hkm-kernel", "bin", "hkm" }, - }; - for (rels) |parts| { - const cand = try std.fs.path.join(allocator, parts); - if (util.fileExists(io, cand)) { - return .{ .path = cand, .source = .self_located, .exists = true }; - } + const pin = try kernelHomePin(allocator, env); + + // 2. A REAL exported HKM_KERNEL_HOME outranks everything below it. + if (pin) |p| { + if (!p.from_config) { + const path = try std.fs.path.join(allocator, &.{ p.value, "bin", "hkm" }); + return .{ .path = path, .source = .kernel_home_env, .exists = util.fileExists(io, path) }; } - } else |_| {} + } + + // 3. Self-locate relative to this launcher's own executable. + if (try selfLocateRoot(allocator, io)) |root| { + const path = try std.fs.path.join(allocator, &.{ root, "bin", "hkm" }); + return .{ .path = path, .source = .self_located, .exists = util.fileExists(io, path) }; + } - // 3. Default for a system package install (Linux .deb → /opt/hkm-kernel). - const def = try std.fs.path.join(allocator, &.{ "/opt", "hkm-kernel", "bin", "hkm" }); + // 4. A config-file pin — the fallback for a custom install layout. + if (pin) |p| { + const path = try std.fs.path.join(allocator, &.{ p.value, "bin", "hkm" }); + return .{ .path = path, .source = .kernel_home_config, .exists = util.fileExists(io, path) }; + } + + // 5. Default for a system package install (Linux .deb → /opt/hkm-kernel). + const def = try std.fs.path.join(allocator, &.{ install_scope.system_root, "bin", "hkm" }); return .{ .path = def, .source = .default, .exists = util.fileExists(io, def) }; } @@ -79,34 +132,79 @@ fn isKernelRoot(io: Io, dir: []const u8) bool { return util.fileExists(io, marker); } +/// The kernel root belonging to THIS launcher, found from its own location. +/// +/// Candidates cover every layout tools/bundle.sh and tools/install.sh produce: +/// macOS .app: /hkm + ../Resources/opt/hkm-kernel +/// Windows zip: /hkm.exe + hkm-kernel +/// portable: /hkm + ../opt/hkm-kernel +/// user install:/bin/hkm + ../lib/hkm-kernel +/// dev monorepo:repo/bin/hkm + repo root +/// +/// Plus one case that is NOT a relative probe: a launcher installed in a system +/// bin directory belongs to the .deb, whose kernel is /opt/hkm-kernel by +/// construction. There is no fixed relative path from /usr/bin to /opt, so +/// without this branch the system launcher has no self-location at all and +/// falls through to whatever pin a user-level installer happened to write — +/// which is exactly the hijack described in the header. +fn selfLocateRoot(allocator: std.mem.Allocator, io: Io) !?[]const u8 { + const dir = std.process.executableDirPathAlloc(io, allocator) catch return null; + const parent = std.fs.path.dirname(dir) orelse dir; + + const rels = [_][]const []const u8{ + &.{ parent, "Resources", "opt", "hkm-kernel" }, // macOS .app (MacOS→Contents) + &.{ dir, "hkm-kernel" }, // windows/portable zip + &.{ parent, "opt", "hkm-kernel" }, // portable + &.{ parent, "lib", "hkm-kernel" }, // install.sh (bin/ + lib/ pairing) + &.{parent}, // dev monorepo: repo/bin/hkm → repo root + }; + for (rels) |parts| { + const cand = try std.fs.path.join(allocator, parts); + if (isKernelRoot(io, cand)) return cand; + } + + if (install_scope.isSystemBinDir(dir) and isKernelRoot(io, install_scope.system_root)) { + return install_scope.system_root; + } + + return null; +} + /// Resolve the kernel ROOT directory (the folder holding composer.json, vendor/, -/// projects/). Used by `run`, the registry, and `hkm-config`. Order: -/// 1. HKM_KERNEL_HOME -/// 2. self-located relative to THIS executable (installed .deb/.app/zip, or the -/// dev monorepo when running repo/bin/hkm) -/// 3. /opt/hkm-kernel default -/// Returns null when no kernel can be found. +/// projects/). Used by `run`, the registry, and `hkm-config`. +/// +/// Same precedence as `resolve` — see the header. Returns null when no kernel +/// can be found. pub fn resolveHome(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !?[]const u8 { - if (env.get("HKM_KERNEL_HOME")) |h| { - if (h.len > 0) return util.trimSlash(h); + return (try resolveHomeDetailed(allocator, io, env)).root; +} + +pub const ResolvedHome = struct { + root: ?[]const u8, + source: Source, +}; + +/// `resolveHome` with provenance, so a caller can act on HOW the kernel was +/// found. `hkm-config check` uses it to avoid writing a pin for a kernel that +/// self-location already reaches — writing one is what created the machine-wide +/// pin that redirected the other install in the first place. +pub fn resolveHomeDetailed(allocator: std.mem.Allocator, io: Io, env: *EnvMap) !ResolvedHome { + const pin = try kernelHomePin(allocator, env); + + if (pin) |p| { + if (!p.from_config) return .{ .root = p.value, .source = .kernel_home_env }; } - if (std.process.executableDirPathAlloc(io, allocator)) |dir| { - // parent = the dir ABOVE the executable's dir (normalized, no ".."). - const parent = std.fs.path.dirname(dir) orelse dir; - const rels = [_][]const []const u8{ - &.{ parent, "Resources", "opt", "hkm-kernel" }, // macOS .app (MacOS→Contents) - &.{ dir, "hkm-kernel" }, // windows/portable zip - &.{ parent, "opt", "hkm-kernel" }, // portable - &.{ parent, "lib", "hkm-kernel" }, - &.{parent}, // dev monorepo: repo/bin/hkm → repo root - }; - for (rels) |parts| { - const cand = try std.fs.path.join(allocator, parts); - if (isKernelRoot(io, cand)) return cand; - } - } else |_| {} - if (isKernelRoot(io, "/opt/hkm-kernel")) return "/opt/hkm-kernel"; - return null; + + if (try selfLocateRoot(allocator, io)) |root| { + return .{ .root = root, .source = .self_located }; + } + + if (pin) |p| return .{ .root = p.value, .source = .kernel_home_config }; + + if (isKernelRoot(io, install_scope.system_root)) { + return .{ .root = install_scope.system_root, .source = .default }; + } + return .{ .root = null, .source = .default }; } /// Resolve the DEVELOPMENT kernel root by walking UP the directory tree from @@ -132,8 +230,81 @@ pub fn resolveDevHome(allocator: std.mem.Allocator, io: Io) !?[]const u8 { pub fn sourceLabel(s: Source) []const u8 { return switch (s) { .cli_path_env => "HKM_CLI_PATH override", - .kernel_home_env => "HKM_KERNEL_HOME override", + .kernel_home_env => "HKM_KERNEL_HOME (exported)", + .kernel_home_config => "HKM_KERNEL_HOME (config.env fallback)", .self_located => "self-located (relative to launcher)", .default => "default (/opt/hkm-kernel)", }; } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "an exported HKM_KERNEL_HOME still overrides everything" { + // The escape hatch has to keep working: a real export is this invocation's + // explicit instruction and must not be demoted along with the config file. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", "/somewhere/custom"); + + const pin = (try kernelHomePin(a, &env)).?; + try std.testing.expectEqualStrings("/somewhere/custom", pin.value); + try std.testing.expect(!pin.from_config); +} + +test "a config.env pin is marked as such so it can be demoted" { + // This is the regression guard for the reported bug: /usr/bin/hkm (v1.3.1) + // resolving its kernel to ~/.local/share/hkm/kernel because a user-level + // install had written that pin into the shared config file. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", "/home/me/.local/share/hkm/kernel"); + try env.put(userconfig.file_keys_marker, "HKM_KERNEL_HOME,HKM_USERDATA_DIR"); + + const pin = (try kernelHomePin(a, &env)).?; + try std.testing.expect(pin.from_config); +} + +test "a pin's trailing slash is trimmed so comparisons hold" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", " /opt/hkm-kernel/ "); + + try std.testing.expectEqualStrings("/opt/hkm-kernel", (try kernelHomePin(a, &env)).?.value); +} + +test "an empty pin is treated as absent, not as the root directory" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", " "); + + try std.testing.expect((try kernelHomePin(a, &env)) == null); +} + +test "every source has a distinct human label" { + // doctor prints these; two sources sharing a label would make the one + // diagnostic that explains a hijack unable to distinguish its two causes. + const sources = [_]Source{ .cli_path_env, .kernel_home_env, .kernel_home_config, .self_located, .default }; + for (sources, 0..) |a, i| { + for (sources[i + 1 ..]) |b| { + try std.testing.expect(!std.mem.eql(u8, sourceLabel(a), sourceLabel(b))); + } + } +} diff --git a/tools/src/lib/userconfig.zig b/tools/src/lib/userconfig.zig index 46174ce..b267e5b 100644 --- a/tools/src/lib/userconfig.zig +++ b/tools/src/lib/userconfig.zig @@ -32,11 +32,31 @@ pub fn path(allocator: std.mem.Allocator, env: *EnvMap) !?[]const u8 { return null; } +/// Sentinel variable recording which keys in `env` came from the CONFIG FILE +/// rather than from the real process environment. +/// +/// The distinction matters because the two carry different authority. A real +/// `export HKM_KERNEL_HOME=…` is this invocation's explicit instruction. A value +/// in config.env is a machine-wide default that BOTH the system launcher +/// (/usr/bin/hkm) and a user launcher (~/.local/bin/hkm) read — so treating it +/// as an override let whichever installer wrote it last silently redirect the +/// other install's kernel. Resolution (lib/kernel.zig) demotes a file-sourced +/// pin below self-location for exactly that reason, and needs this to tell them +/// apart after load() has flattened both into one map. +pub const file_keys_marker = "HKM_CONFIG_FILE_KEYS"; + /// Load KEY=VALUE lines into `env`, WITHOUT overriding keys already set in the /// real environment. Silently no-ops if the file is absent. Best-effort. +/// +/// Also records the loaded keys under `file_keys_marker`, so a later reader can +/// ask whether a value was the operator's explicit export or just the config +/// file's default. See `isFileSourced`. pub fn load(allocator: std.mem.Allocator, io: Io, env: *EnvMap) void { const cfg = (path(allocator, env) catch return) orelse return; const content = Dir.cwd().readFileAlloc(io, cfg, allocator, .limited(64 * 1024)) catch return; + + var sourced: std.ArrayList(u8) = .empty; + var lines = std.mem.splitScalar(u8, content, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); @@ -48,7 +68,25 @@ pub fn load(allocator: std.mem.Allocator, io: Io, env: *EnvMap) void { // Process env wins — only fill in what isn't already set. if (env.get(key) != null) continue; env.put(key, val) catch continue; + + if (sourced.items.len > 0) sourced.append(allocator, ',') catch {}; + sourced.appendSlice(allocator, key) catch {}; + } + + if (sourced.items.len > 0) env.put(file_keys_marker, sourced.items) catch {}; +} + +/// Did `key`'s current value in `env` come from the config file? +/// +/// False for a key the operator exported themselves (load() skips those), and +/// false in any process that never called load(). +pub fn isFileSourced(env: *EnvMap, key: []const u8) bool { + const list = env.get(file_keys_marker) orelse return false; + var it = std.mem.splitScalar(u8, list, ','); + while (it.next()) |k| { + if (std.mem.eql(u8, k, key)) return true; } + return false; } /// Read a single key from the config file (not the environment). Null if absent. @@ -103,3 +141,76 @@ pub fn set(allocator: std.mem.Allocator, io: Io, env: *EnvMap, key: []const u8, // Owner-only: this file may later hold overrides an operator considers private. @import("util.zig").chmod600(io, cfg); } + +/// Remove KEY from the config file, preserving every other line. Returns true +/// when a line was actually removed. +/// +/// The counterpart to `set`, and needed for one specific repair: a stale +/// `HKM_KERNEL_HOME` pointing at an install that no longer exists (or at the +/// OTHER scope's kernel). Repointing it perpetuates a machine-wide pin that +/// both launchers read; deleting it hands resolution back to self-location, +/// where each launcher finds its own kernel and neither can affect the other. +pub fn unset(allocator: std.mem.Allocator, io: Io, env: *EnvMap, key: []const u8) !bool { + const cfg = (try path(allocator, env)) orelse return error.MissingHome; + + const content = Dir.cwd().readFileAlloc(io, cfg, allocator, .limited(64 * 1024)) catch return false; + + var out: std.ArrayList(u8) = .empty; + var removed = false; + var lines = std.mem.splitScalar(u8, content, '\n'); + while (lines.next()) |raw| { + const line = std.mem.trim(u8, raw, "\r"); + if (line.len == 0) continue; + const eq = std.mem.indexOfScalar(u8, line, '='); + if (eq != null and std.mem.eql(u8, std.mem.trim(u8, line[0..eq.?], " \t"), key)) { + removed = true; + continue; + } + try out.appendSlice(allocator, line); + try out.append(allocator, '\n'); + } + if (!removed) return false; + + try Dir.cwd().writeFile(io, .{ .sub_path = cfg, .data = out.items }); + @import("util.zig").chmod600(io, cfg); + return true; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test "isFileSourced separates a config default from an explicit export" { + // The whole point: a value the operator exported is this invocation's + // instruction, while a value from config.env is a machine-wide default that + // BOTH launchers read. Conflating them let a user install's pin redirect the + // system launcher's kernel. + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + + try env.put(file_keys_marker, "HKM_KERNEL_HOME,HKM_USERDATA_DIR"); + + try std.testing.expect(isFileSourced(&env, "HKM_KERNEL_HOME")); + try std.testing.expect(isFileSourced(&env, "HKM_USERDATA_DIR")); + try std.testing.expect(!isFileSourced(&env, "HKM_DEV_HOME")); + // A prefix of a listed key must not match — splitting on ',' is what makes + // that true, and a substring search would not. + try std.testing.expect(!isFileSourced(&env, "HKM_KERNEL")); +} + +test "isFileSourced is false when nothing was loaded" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var env = EnvMap.init(a); + defer env.deinit(); + try env.put("HKM_KERNEL_HOME", "/opt/hkm-kernel"); + + // No marker → the value can only have come from the real environment. + try std.testing.expect(!isFileSourced(&env, "HKM_KERNEL_HOME")); +} diff --git a/tools/src/main.zig b/tools/src/main.zig index 8f1cfbd..6439d39 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -10,6 +10,7 @@ const ui_cmd = @import("commands/ui.zig"); const cli_cmd = @import("commands/cli.zig"); const doctor_cmd = @import("commands/doctor.zig"); const upgrade_cmd = @import("commands/upgrade.zig"); +const version_cmd = @import("commands/version.zig"); const kernel = @import("lib/kernel.zig"); const util = @import("lib/util.zig"); const userconfig = @import("lib/userconfig.zig"); @@ -31,10 +32,10 @@ fn printHelp(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ prompt.item("hkm module [create|delete]", "scaffold a first-party kernel package (modules/)"); prompt.item("hkm ui [sync|list|link|clean]", "federate enabled plugins' UIs into the frontend"); prompt.item("hkm update ", "refresh a project's kernel registry entry"); - prompt.item("hkm upgrade [--check]", "check for / apply a kernel update"); - prompt.item("hkm upgrade --local", "install THIS checkout over the installed kernel"); + prompt.item("hkm upgrade [--check]", "update YOUR install; sudo hkm upgrade updates the system one"); + prompt.item("hkm upgrade --local", "install THIS checkout over an installed kernel"); prompt.item("hkm doctor", "diagnose the local environment"); - prompt.item("hkm version", "show the HKM banner + version (also --version, -v)"); + prompt.item("hkm version", "kernel version in each install scope (also --version, -v)"); prompt.item("hkm help", "show this help"); prompt.item("hkm --dev", "use the development kernel (this monorepo) instead of the installed stable copy"); prompt.item("hkm --mem", "print the memory inspector dashboard when the command finishes (debug builds)"); @@ -253,8 +254,9 @@ fn dispatch(init: std.process.Init.Minimal, mm: *memory.Manager) !u8 { return 0; } if (std.mem.eql(u8, cmd, "version")) { - banner.print(allocator, io, &env_map); - return 0; + var scope = CmdScope.begin(mm, "version"); + defer scope.end(); + return try version_cmd.run(scope.allocator(), io, &env_map, args); } if (std.mem.eql(u8, cmd, "upgrade") or std.mem.eql(u8, cmd, "self-update")) { var scope = CmdScope.begin(mm, "upgrade"); diff --git a/tools/src/stamp.zig b/tools/src/stamp.zig index cae4993..602a197 100644 --- a/tools/src/stamp.zig +++ b/tools/src/stamp.zig @@ -2,34 +2,25 @@ //! //! stamp //! -//! Run from build.zig so a versioned build carries its version everywhere, -//! not just in the compiled binary. +//! Run from build.zig so a versioned build carries its version everywhere, not +//! just in the compiled binary. //! -//! WHY THIS IS NARROW ON PURPOSE -//! ----------------------------- -//! A hard-coded "version" in composer.json normally does more harm than good: -//! Composer derives a package's version from its git tags, and a literal field -//! OVERRIDES that. Once the two can disagree, they eventually do — someone tags -//! v1.2.0 and forgets the field, and every consumer resolves the stale number -//! with no error anywhere. This repository has already been bitten by it once -//! (phpshots/bind-it pinned "0.1.3" in composer.json and its real tags were -//! ignored). +//! The parsing, validation and rewriting all live in lib/composer_version.zig, +//! because `hkm version` and `hkm upgrade` READ the field this writes. When the +//! two halves lived in separate copies, a reader and a writer that disagreed +//! about what counts as a version would surface as an installed kernel +//! reporting the wrong number, with nothing pointing at the cause. //! -//! It earns its place here for one reason: the native distribution ships -//! WITHOUT a .git directory. A .deb or a zip has no tags to derive from, so the -//! field is the only version marker the installed kernel has. -//! -//! Hence the rule build.zig applies: stamp only when an explicit -Dversion was -//! passed — which is what tools/bundle.sh does for a release. A plain `zig build` -//! leaves composer.json untouched, so a dev build never dirties the working tree -//! with "0.0.0-dev" that someone then commits by accident. -//! -//! The edit is textual rather than a JSON re-serialise so the file keeps its -//! hand-maintained key order, indentation and comments-by-convention. Rewriting -//! it through a JSON encoder would reorder every key and produce an unreadable -//! diff on every release. +//! WHY STAMPING IS NARROW ON PURPOSE +//! --------------------------------- +//! build.zig stamps only when an explicit -Dversion was passed — which is what +//! tools/bundle.sh does for a release. A plain `zig build` leaves composer.json +//! untouched, so a dev build never dirties the working tree with "0.0.0-dev" +//! that someone then commits by accident. See lib/composer_version.zig for why +//! the field is a liability in a git checkout and necessary in a bundle. const std = @import("std"); +const composer_version = @import("lib/composer_version.zig"); pub fn main(init: std.process.Init.Minimal) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); @@ -47,14 +38,7 @@ pub fn main(init: std.process.Init.Minimal) !void { } const path = args[1]; - // Whitespace from both ends, then ONE leading 'v'. - // - // This used to be trim(..., " \t\r\nv"), which strips the cutset from BOTH - // ends — so any version ENDING in 'v' lost it: "1.1.0-dev" became - // "1.1.0-de", which then failed validation and silently skipped stamping. - var version = std.mem.trim(u8, args[2], " \t\r\n"); - if (version.len > 0 and (version[0] == 'v' or version[0] == 'V')) version = version[1..]; - + const version = composer_version.normalize(args[2]); if (version.len == 0) return; // nothing meaningful to stamp // A version Composer cannot parse is far worse than no version at all: @@ -70,13 +54,13 @@ pub fn main(init: std.process.Init.Minimal) !void { // into something Composer likes — which would make composer.json disagree // with the tag it was built from — the field is simply left out. It is // optional; a broken install is not. - if (!composerValid(version)) { + if (!composer_version.composerValid(version)) { // A `git describe` version ("1.1.0-dev.2-12-g29dccfb") is what every // build from a checkout between releases looks like. It is EXPECTED to // be unstampable, so saying so on every single dev build trains people // to ignore the message — and then they ignore it on the release build // where it matters. Skip quietly for that shape; warn for anything else. - if (isDescribeVersion(version)) return; + if (composer_version.isDescribeVersion(version)) return; // A version longer than the buffer would make bufPrint fail, and // returning there skipped the marker with NO diagnostic at all — the @@ -98,377 +82,6 @@ pub fn main(init: std.process.Init.Minimal) !void { // in checkouts and in staging trees that do not carry one. const source = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch return; - const updated = try stamp(allocator, source, version) orelse return; // already correct + const updated = try composer_version.stamp(allocator, source, version) orelse return; // already correct try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = updated }); } - -/// Semver build metadata: dot-separated identifiers of [0-9A-Za-z-], each -/// non-empty. Deliberately strict — this string is written verbatim into JSON. -fn validMetadata(meta: []const u8) bool { - if (meta.len == 0) return false; - - var it = std.mem.splitScalar(u8, meta, '.'); - while (it.next()) |ident| { - if (ident.len == 0) return false; - for (ident) |c| { - if (!std.ascii.isAlphanumeric(c) and c != '-') return false; - } - } - return true; -} - -/// Nothing that would break out of a JSON string, whatever validation decided. -/// composerValid() is the gate; this is the seatbelt, because the cost of being -/// wrong is a composer.json no install can parse. -fn jsonSafe(v: []const u8) bool { - for (v) |c| { - if (c == '"' or c == '\\' or c < 0x20 or c == 0x7f) return false; - } - return true; -} - -/// Does this look like `git describe` output — "--g"? -/// -/// Matched on the trailing "--g" only, so a real pre-release -/// ("1.1.0-beta.1") is not mistaken for one and still gets the warning. -fn isDescribeVersion(v: []const u8) bool { - const g = std.mem.lastIndexOfScalar(u8, v, '-') orelse return false; - const sha = v[g + 1 ..]; - if (sha.len < 2 or sha[0] != 'g') return false; - for (sha[1..]) |c| { - if (!std.ascii.isHex(c)) return false; - } - - const head = v[0..g]; - const d = std.mem.lastIndexOfScalar(u8, head, '-') orelse return false; - const count = head[d + 1 ..]; - if (count.len == 0) return false; - for (count) |c| { - if (!std.ascii.isDigit(c)) return false; - } - return true; -} - -/// Whether Composer will accept this as a package version. -/// -/// A deliberately CONSERVATIVE subset of Composer's own pattern: numeric parts, -/// then an optional stability tag, then an optional `-dev`. Anything it is not -/// sure about is rejected, because the failure mode of a false accept (an -/// install that cannot resolve dependencies) is much worse than a false reject -/// (no version field, which is the status quo for this repository anyway). -fn composerValid(v: []const u8) bool { - var s_ = v; - if (s_.len == 0) return false; - if (s_[0] == 'v' or s_[0] == 'V') s_ = s_[1..]; - - // Build metadata is allowed, but it still has to BE metadata. Discarding it - // unchecked let anything through — `composerValid("1.1.0+\"")` returned - // true, and stamp() writes the version raw between JSON quotes, so that one - // input produced an unparseable composer.json. Semver defines metadata as - // dot-separated [0-9A-Za-z-] identifiers; anything else is rejected. - if (std.mem.indexOfScalar(u8, s_, '+')) |i| { - if (!validMetadata(s_[i + 1 ..])) return false; - s_ = s_[0..i]; - } - if (s_.len == 0) return false; - - // 1-4 numeric components separated by '.' or '-'. - var i: usize = 0; - var parts: usize = 0; - while (i < s_.len and parts < 4) { - const start = i; - while (i < s_.len and std.ascii.isDigit(s_[i])) i += 1; - if (i == start) return false; // expected a number - parts += 1; - if (i < s_.len and (s_[i] == '.' or s_[i] == '-')) { - // Only continue the numeric run when a digit follows. - if (i + 1 < s_.len and std.ascii.isDigit(s_[i + 1])) { - i += 1; - continue; - } - } - break; - } - if (parts == 0) return false; - if (i == s_.len) return true; // plain numeric version - - // Optional separator before the stability tag. - if (s_[i] == '.' or s_[i] == '-' or s_[i] == '_') i += 1; - if (i == s_.len) return false; // trailing separator - - const tail = s_[i..]; - - // Bare "dev" is the only form Composer accepts — no counter after it. - if (std.ascii.eqlIgnoreCase(tail, "dev")) return true; - if (std.ascii.eqlIgnoreCase(tail, "x-dev")) return true; - - // stability tag, optionally followed by (.|-)?digits, repeated. - const tags = [_][]const u8{ "stable", "beta", "alpha", "patch", "rc", "pl", "b", "a", "p" }; - for (tags) |tag| { - if (tail.len < tag.len) continue; - if (!std.ascii.eqlIgnoreCase(tail[0..tag.len], tag)) continue; - - var rest = tail[tag.len..]; - while (rest.len > 0) { - if (rest[0] == '.' or rest[0] == '-') rest = rest[1..]; - if (rest.len == 0) return false; // trailing separator - const start = rest.len; - while (rest.len > 0 and std.ascii.isDigit(rest[0])) rest = rest[1..]; - if (rest.len == start) return false; // expected digits - } - return true; - } - - return false; -} - -/// Return the file with `version` applied, or null when it is already correct. -/// -/// Exposed for testing. -pub fn stamp(allocator: std.mem.Allocator, source: []const u8, version: []const u8) !?[]const u8 { - // The version is written raw between JSON quotes below, so refuse outright - // anything that could terminate the string or embed a control character. - if (!jsonSafe(version)) return null; - - if (findVersionValue(source)) |span| { - if (std.mem.eql(u8, source[span.start..span.end], version)) return null; // no-op - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(allocator, source[0..span.start]); - try out.appendSlice(allocator, version); - try out.appendSlice(allocator, source[span.end..]); - return try out.toOwnedSlice(allocator); - } - - // No "version" key: insert one directly after "name", which is where a - // reader looks for it and where composer's own docs put it. - const anchor = std.mem.indexOf(u8, source, "\"name\"") orelse return null; - const line_end = std.mem.indexOfScalarPos(u8, source, anchor, '\n') orelse return null; - - const indent = detectIndent(source, anchor); - - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(allocator, source[0 .. line_end + 1]); - try out.appendSlice(allocator, indent); - try out.appendSlice(allocator, "\"version\": \""); - try out.appendSlice(allocator, version); - try out.appendSlice(allocator, "\",\n"); - try out.appendSlice(allocator, source[line_end + 1 ..]); - return try out.toOwnedSlice(allocator); -} - -const Span = struct { start: usize, end: usize }; - -/// Byte range of the STRING VALUE of a top-level "version" key. -fn findVersionValue(source: []const u8) ?Span { - var search: usize = 0; - while (std.mem.indexOfPos(u8, source, search, "\"version\"")) |key_at| { - search = key_at + 9; - - // Step over whitespace and the colon. - var i = key_at + 9; - while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; - if (i >= source.len or source[i] != ':') continue; - i += 1; - while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; - if (i >= source.len or source[i] != '"') continue; - - const start = i + 1; - const end = std.mem.indexOfScalarPos(u8, source, start, '"') orelse return null; - return .{ .start = start, .end = end }; - } - return null; -} - -/// The leading whitespace of the line containing `pos`, so an inserted key -/// matches the file's existing indentation rather than imposing a new one. -fn detectIndent(source: []const u8, pos: usize) []const u8 { - var line_start = pos; - while (line_start > 0 and source[line_start - 1] != '\n') line_start -= 1; - - var i = line_start; - while (i < source.len and (source[i] == ' ' or source[i] == '\t')) i += 1; - return source[line_start..i]; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -test "replaces an existing version value" { - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "alfacode-team/php-service-platform", - \\ "version": "1.0.0", - \\ "type": "library" - \\} - ; - const out = (try stamp(a, src, "1.0.21")).?; - defer a.free(out); - - try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"1.0.21\"") != null); - try std.testing.expect(std.mem.indexOf(u8, out, "1.0.0") == null); - // Everything else must be untouched. - try std.testing.expect(std.mem.indexOf(u8, out, "\"type\": \"library\"") != null); -} - -test "inserts the key after name when absent, matching indentation" { - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "alfacode-team/php-service-platform", - \\ "type": "library" - \\} - ; - const out = (try stamp(a, src, "1.0.21")).?; - defer a.free(out); - - try std.testing.expect(std.mem.indexOf(u8, out, " \"version\": \"1.0.21\",\n") != null); - // It must still parse. - var arena = std.heap.ArenaAllocator.init(a); - defer arena.deinit(); - const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), out, .{}); - try std.testing.expectEqualStrings("1.0.21", parsed.object.get("version").?.string); -} - -test "an already-correct version is a no-op" { - // Returning null keeps the build from rewriting the file (and dirtying the - // working tree) on every single invocation. - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "x/y", - \\ "version": "1.0.21" - \\} - ; - try std.testing.expect((try stamp(a, src, "1.0.21")) == null); -} - -test "does not mistake a nested version for the package's own" { - // "require" blocks are full of version-looking keys; only a top-level - // "version" KEY should ever be rewritten. - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "x/y", - \\ "require": { "php": ">=8.4" } - \\} - ; - const out = (try stamp(a, src, "2.0.0")).?; - defer a.free(out); - - var arena = std.heap.ArenaAllocator.init(a); - defer arena.deinit(); - const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena.allocator(), out, .{}); - try std.testing.expectEqualStrings("2.0.0", parsed.object.get("version").?.string); - try std.testing.expectEqualStrings(">=8.4", parsed.object.get("require").?.object.get("php").?.string); -} - -test "a leading v is stripped so composer sees a bare version" { - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "x/y" - \\} - ; - // main() trims the 'v'; stamp() receives it already trimmed. Assert the - // shape composer expects. - const out = (try stamp(a, src, "1.0.21")).?; - defer a.free(out); - try std.testing.expect(std.mem.indexOf(u8, out, "\"version\": \"v") == null); -} - -test "accepts the versions composer accepts" { - // Verified against `composer validate` before being encoded here. - for ([_][]const u8{ - "1.1.0", "1.0.21", "v1.1.0", "1.1.0-dev", "1.1.0-beta.2", - "1.1.0-RC2", "1.1.0-alpha.2", "1.2.3.4", "1.1.0+meta", - }) |v| { - try std.testing.expect(composerValid(v)); - } -} - -test "rejects the version that broke a real install" { - // "1.1.0-dev.2" was stamped from a git tag and made `composer install` - // abort on every machine that took the update. Composer's dev suffix takes - // no counter. - try std.testing.expect(!composerValid("1.1.0-dev.2")); - try std.testing.expect(!composerValid("1.1.0-dev2")); -} - -test "rejects anything it cannot vouch for" { - for ([_][]const u8{ - "", "v", "abc", "1.1.0-", "1.1.0-nonsense", "1.1.0-beta.", "-1.0.0", - }) |v| { - try std.testing.expect(!composerValid(v)); - } -} - -test "a version ending in 'v' keeps its last character" { - // Regression: main() trimmed the cutset " \t\r\nv" from BOTH ends, so - // "1.1.0-dev" arrived as "1.1.0-de" and was rejected as invalid — the one - // pre-release form Composer actually accepts. Caught by cross-checking - // against `composer validate`, not by the unit tests, which called the - // validator directly and skipped the trimming. - try std.testing.expect(composerValid("1.1.0-dev")); - try std.testing.expect(!composerValid("1.1.0-de")); -} - -test "build metadata is validated, not waved through" { - // The bug: metadata was discarded unchecked, so this returned true — and - // stamp() writes the version raw between JSON quotes, producing a - // composer.json no install can parse. - try std.testing.expect(!composerValid("1.1.0+\"")); - try std.testing.expect(!composerValid("1.1.0+a\\b")); - try std.testing.expect(!composerValid("1.1.0+a\nb")); - try std.testing.expect(!composerValid("1.1.0+")); // empty metadata - try std.testing.expect(!composerValid("1.1.0+a..b")); // empty identifier - try std.testing.expect(!composerValid("1.1.0+a b")); - - // …while real metadata still passes. - try std.testing.expect(composerValid("1.1.0+build.1")); - try std.testing.expect(composerValid("1.1.0+20260812")); - try std.testing.expect(composerValid("1.1.0+g29dccfb")); - try std.testing.expect(composerValid("1.1.0-beta.1+exp.sha.5114f85")); -} - -test "stamp refuses a version that could break out of the JSON string" { - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "acme/pkg", - \\ "type": "library" - \\} - ; - for ([_][]const u8{ "1.0.0+\"", "1.0.0\\", "1.0.0\n", "1.0.0\x7f" }) |bad| { - try std.testing.expect((try stamp(a, src, bad)) == null); - } -} - -test "a stamped composer.json is still parseable JSON" { - const a = std.testing.allocator; - const src = - \\{ - \\ "name": "acme/pkg", - \\ "type": "library" - \\} - ; - const out = (try stamp(a, src, "1.2.0")) orelse return error.ExpectedOutput; - defer a.free(out); - - const parsed = try std.json.parseFromSlice(std.json.Value, a, out, .{}); - defer parsed.deinit(); - try std.testing.expectEqualStrings("1.2.0", parsed.value.object.get("version").?.string); -} - -test "a git describe version is recognised so dev builds stay quiet" { - try std.testing.expect(isDescribeVersion("1.1.0-dev.2-12-g29dccfb")); - try std.testing.expect(isDescribeVersion("1.0.21-138-gbdbbf34")); - - // A real pre-release must NOT be mistaken for one: those are release - // intents, and silently skipping them is how a release ships unstamped. - try std.testing.expect(!isDescribeVersion("1.1.0-beta.1")); - try std.testing.expect(!isDescribeVersion("1.1.0-dev.2")); - try std.testing.expect(!isDescribeVersion("1.1.0")); - try std.testing.expect(!isDescribeVersion("1.1.0-12-gzz")); -} diff --git a/tools/src/templates/app/bootstrap/kernel-autoload.php b/tools/src/templates/app/bootstrap/kernel-autoload.php index 3b95735..455a3e5 100644 --- a/tools/src/templates/app/bootstrap/kernel-autoload.php +++ b/tools/src/templates/app/bootstrap/kernel-autoload.php @@ -109,7 +109,7 @@ function psp_require_kernel_autoload(): void // (3) The installed kernel, via HKM_KERNEL_HOME. // // This is how `hkm` installs itself — a system install under - // /opt/hkm-kernel, or a user install under ~/.local/share/hkm/kernel — + // /opt/hkm-kernel, or a user install under ~/.local/lib/hkm-kernel — // and without it that kernel is invisible to PHP. `hkm run` papered // over the gap by exporting PSP_GLOBAL_AUTOLOAD for its child, so the // dev server worked and NOTHING else did: the same project served by @@ -127,18 +127,24 @@ function psp_require_kernel_autoload(): void $candidates[] = rtrim($composerHome, '/\\') . '/vendor/autoload.php'; } - // (5)+(6) Default global Composer homes on Linux/macOS, plus the - // standard `hkm upgrade --user` install path — the one place a kernel - // lands when the operator has no root and never exported anything. + // (5)+(6) Default global Composer homes on Linux/macOS, plus the user + // install path — the one place a kernel lands when the operator has no + // root and never exported anything. + // + // The user path is tried BEFORE the system one below. A machine can + // hold both, and the user install is the one that user chose to manage + // (`hkm upgrade` targets it without root); falling to /opt first would + // run a kernel they may not even have write access to. $home = getenv('HOME'); if (is_string($home) && $home !== '') { $home = rtrim($home, '/\\'); $candidates[] = $home . '/.config/composer/vendor/autoload.php'; // current default $candidates[] = $home . '/.composer/vendor/autoload.php'; // legacy default - $candidates[] = $home . '/.local/share/hkm/kernel/vendor/autoload.php'; + $candidates[] = $home . '/.local/lib/hkm-kernel/vendor/autoload.php'; // install.sh / hkm upgrade --user + $candidates[] = $home . '/.local/share/hkm/kernel/vendor/autoload.php'; // pre-1.4 --user target } - // (7) The system install path used by the .deb / install.sh. + // (7) The system install path used by the .deb. $candidates[] = '/opt/hkm-kernel/vendor/autoload.php'; // Try each candidate; the first one that makes the kernel class diff --git a/tools/src/tests.zig b/tools/src/tests.zig index 13f95c7..2a02c07 100644 --- a/tools/src/tests.zig +++ b/tools/src/tests.zig @@ -36,9 +36,12 @@ test { _ = @import("commands/ui.zig"); _ = @import("commands/update.zig"); _ = @import("commands/upgrade.zig"); + _ = @import("commands/version.zig"); _ = @import("config.zig"); _ = @import("constants.zig"); _ = @import("lib/banner.zig"); + _ = @import("lib/composer_version.zig"); + _ = @import("lib/install_scope.zig"); _ = @import("lib/inspector/dashboard.zig"); _ = @import("lib/inspector/meminspector.zig"); _ = @import("lib/inspector/tracked.zig");