diff --git a/docs/operations.md b/docs/operations.md index d5964fa..131768d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -22,6 +22,31 @@ install/verify.ps1 Scheduled sync will be added on top of the same update and verify entrypoints. +## Port Registry + +Session start hooks append events and also synchronize project-local port +declarations into the machine-wide registry. + +Source-of-truth file: + +- `~/.copilot-resources-state/project-ports-registry.json` + +Project-local declaration file: + +- `.local/project-ports.json` + +Manual sync for the current workspace: + +```bash +node ~/.copilot-resources/resources/scripts/update-port-registry.mjs +``` + +Conflict report: + +```bash +node ~/.copilot-resources/resources/scripts/update-port-registry.mjs --report +``` + ## Audit ```bash diff --git a/docs/setup.md b/docs/setup.md index 8106db7..8fef1fe 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -20,6 +20,7 @@ install/bootstrap.ps1 - Generates managed VS Code and Copilot CLI MCP config files from the tracked templates in `config/mcp/` - Writes a managed Copilot CLI environment fragment and sources it from the shell or PowerShell profile - Creates `.local/mcp.local.jsonc` from the tracked example if the machine-local MCP override file does not exist yet +- Creates a project-local port declaration file at `.local/project-ports.json` on first session start if it does not exist yet - Writes a local install-state file outside the repository ## Optional Settings diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1e6748f..c7f104e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -31,6 +31,22 @@ session starts: The shared hook now invokes the shell script through `bash`, so the session start hook no longer depends on the script file itself being executable. +## Port Registry Not Updating + +If `~/.copilot-resources-state/project-ports-registry.json` is missing or stale: + +- Run `install/verify.sh --quick` and confirm the hook script paths exist. +- Run `node ~/.copilot-resources/resources/scripts/update-port-registry.mjs`. +- Check `~/.copilot-resources-state/project-ports-errors.log` for parse or + write failures. + +If `.local/project-ports.json` contains invalid JSON, fix the JSON and re-run +the sync command. + +To review collisions and recommended project changes: + +- Run `node ~/.copilot-resources/resources/scripts/update-port-registry.mjs --report`. + ## Publish Refused To Overwrite The publish scripts stop on collisions by default. Use a new name or rerun with diff --git a/install/merge-managed-mcp-config.mjs b/install/merge-managed-mcp-config.mjs index bf3f876..caf8ad1 100644 --- a/install/merge-managed-mcp-config.mjs +++ b/install/merge-managed-mcp-config.mjs @@ -205,6 +205,16 @@ function parseJsonc(input, label) { } function parseJsoncAst(input, label) { + if (!input.trim()) { + return { + type: "object", + start: 0, + end: 0, + properties: [], + value: {}, + }; + } + let index = 0; function fail(message) { @@ -780,9 +790,10 @@ function main() { const allManagedServerNames = new Set(Object.keys(managedConfig[options.serverKey])); const desiredServerNames = new Set(Object.keys(managedServers)); - const targetText = fs.existsSync(options.target) + const existingTargetText = fs.existsSync(options.target) ? fs.readFileSync(options.target, "utf8") - : "{}\n"; + : ""; + const targetText = existingTargetText.trim() ? existingTargetText : "{}\n"; const targetAst = parseJsoncAst(targetText, options.target); const cleanedTargetText = removeStaleManagedServers( targetText, diff --git a/install/merge-managed-mcp-config.test.mjs b/install/merge-managed-mcp-config.test.mjs index 64810ee..de829e1 100644 --- a/install/merge-managed-mcp-config.test.mjs +++ b/install/merge-managed-mcp-config.test.mjs @@ -203,6 +203,25 @@ test("preserves comments and custom servers while pruning stale managed MCP entr assert.equal(parsed.servers.gitea, undefined); }); +test("creates managed MCP config from an empty target file", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-")); + const targetFile = path.join(tempDir, "mcp.json"); + + fs.writeFileSync(targetFile, "", "utf8"); + + const result = runMerge({ + targetFile, + templateFile: vscodeTemplateFile, + serverKey: "servers", + }); + assert.equal(result.status, 0, result.stderr); + + const output = fs.readFileSync(targetFile, "utf8"); + const parsed = parseJsonc(output); + assert.equal(parsed.servers.playwright.command, "npx"); + assert.equal(parsed.servers.filesystem.command, "docker"); +}); + test("renders optional Gitea config when local overrides are complete", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-")); const targetFile = path.join(tempDir, "mcp-config.json"); diff --git a/install/verify.ps1 b/install/verify.ps1 index 96e96ee..880d158 100644 --- a/install/verify.ps1 +++ b/install/verify.ps1 @@ -58,7 +58,10 @@ Assert-Path -Label 'hooks link' -Path (Join-Path $CopilotHome 'hooks') Assert-Path -Label 'session start hook' -Path (Join-Path $CanonicalHome 'resources\hooks\session-audit.json') Assert-Path -Label 'session start hook script' -Path (Join-Path $CanonicalHome 'resources\scripts\report-hook-event.sh') Assert-ReadableFile -Label 'session start hook script' -Path (Join-Path $CanonicalHome 'resources\scripts\report-hook-event.sh') +Assert-Path -Label 'port registry updater script' -Path (Join-Path $CanonicalHome 'resources\scripts\update-port-registry.mjs') +Assert-ReadableFile -Label 'port registry updater script' -Path (Join-Path $CanonicalHome 'resources\scripts\update-port-registry.mjs') Assert-Command -Label 'session start hook shell' -CommandName 'bash' +Assert-Command -Label 'port registry updater runtime' -CommandName 'node' Assert-Path -Label 'prompts link' -Path (Join-Path $VscodeUserDir 'prompts') Assert-Path -Label 'VS Code MCP config' -Path (Join-Path $VscodeUserDir 'mcp.json') Assert-Path -Label 'Copilot CLI MCP config' -Path (Join-Path $CopilotHome 'mcp-config.json') diff --git a/install/verify.sh b/install/verify.sh index 8a07dd0..3737142 100755 --- a/install/verify.sh +++ b/install/verify.sh @@ -75,6 +75,7 @@ main() { local local_mcp_overrides_file="$canonical_home/.local/mcp.local.jsonc" local session_start_hook_file="$canonical_home/resources/hooks/session-audit.json" local session_start_hook_script="$canonical_home/resources/scripts/report-hook-event.sh" + local port_registry_script="$canonical_home/resources/scripts/update-port-registry.mjs" check_path "repo root" "$repo_root" check_path "canonical home" "$canonical_home" @@ -85,7 +86,10 @@ main() { check_path "session start hook" "$session_start_hook_file" check_path "session start hook script" "$session_start_hook_script" check_readable_file "session start hook script" "$session_start_hook_script" + check_path "port registry updater script" "$port_registry_script" + check_readable_file "port registry updater script" "$port_registry_script" check_command "session start hook shell" "bash" + check_command "port registry updater runtime" "node" check_path "prompts link" "$vscode_user_dir/prompts" check_path "VS Code MCP config" "$vscode_mcp_file" check_path "Copilot CLI MCP config" "$copilot_cli_mcp_file" diff --git a/resources/instructions/port-registry.instructions.md b/resources/instructions/port-registry.instructions.md new file mode 100644 index 0000000..29a42e6 --- /dev/null +++ b/resources/instructions/port-registry.instructions.md @@ -0,0 +1,14 @@ +--- +name: "Shared Port Registry Workflow" +description: "Use when working in projects that share development ports. Keep declared ports in project-local JSON and synchronize to the machine-wide source-of-truth registry." +applyTo: "**" +--- + +- Track each project's declared ports in `.local/project-ports.json` with an array field named `ports`. +- Use entries shaped like `{ "service": "web", "port": 3000, "protocol": "tcp" }`. +- Treat `.local/project-ports.json` as the writable project-local declaration source. +- Synchronize declarations to the machine-wide source-of-truth file at `~/.copilot-resources-state/project-ports-registry.json` using `node ~/.copilot-resources/resources/scripts/update-port-registry.mjs`. +- When a conflict is reported for a port, recommend changing the conflicting unlogged or newly introduced project first. +- Do not change an existing logged incumbent project's port unless the user explicitly asks. +- After changing ports in the new project, re-sync so both JSON files become consistent. +- Use `node ~/.copilot-resources/resources/scripts/update-port-registry.mjs --report` to inspect current conflicts and recommendations. diff --git a/resources/prompts/scaffold-discord-oauth-vue3-vite.prompt.md b/resources/prompts/scaffold-discord-oauth-vue3-vite.prompt.md new file mode 100644 index 0000000..fe8b12a --- /dev/null +++ b/resources/prompts/scaffold-discord-oauth-vue3-vite.prompt.md @@ -0,0 +1,19 @@ +--- +name: "scaffold-discord-oauth-vue3-vite" +description: "Scaffold a full-stack Vue 3 + Vite Discord OAuth setup: server bundle under src/server with PKCE, sessions, and allowlist support, plus client-side composable, DiscordAuthWidget organism, callback page, router guard, and Vite proxy." +agent: "agent" +tools: [read, search, execute, edit] +argument-hint: "project-root= mode= frontend-origin= allowlist-discord-ids=" +--- + +Scaffold Discord OAuth for the target project following the discord-oauth-vue3-vite skill procedure. + +Requirements: + +- Prefer `resources/scripts/scaffold-discord-oauth-vue3-vite.sh` when the shared resources repo is available in the current workspace or agent context. +- If that script path is not available, do not stop. Fall back to the skill procedure and scaffold the `src/server/discord-oauth` bundle manually. +- Default to `--mode dry-run` unless the user explicitly asks for apply mode. +- Keep the generated auth bundle under `src/server/`. +- After the server bundle, place the client-side files using the reference templates in `resources/templates/discord-oauth-vue3-vite/src/client/`. Adapt component tier paths and SCSS `@use` imports to the target project's conventions. For projects using atomic design, colocated SCSS and a stories stub are required for the `DiscordAuthWidget` organism. +- Summarize the generated server files, the client files placed, the env vars the user still needs to set, and the runtime wiring step. +- Include a concrete next action: run `node src/server/discord-oauth/server.js` in a separate terminal (or add an `auth:dev` script) and configure the Vite proxy for `/api/auth`. \ No newline at end of file diff --git a/resources/scripts/report-hook-event.ps1 b/resources/scripts/report-hook-event.ps1 index c7de948..a30515b 100644 --- a/resources/scripts/report-hook-event.ps1 +++ b/resources/scripts/report-hook-event.ps1 @@ -5,4 +5,15 @@ New-Item -ItemType Directory -Path $StateDir -Force | Out-Null $Payload = [Console]::In.ReadToEnd() $Payload | Add-Content -LiteralPath (Join-Path $StateDir 'hook-events.log') + +$PortRegistryScript = Join-Path $PSScriptRoot 'update-port-registry.mjs' +$NodeCommand = Get-Command node -ErrorAction SilentlyContinue +if ($NodeCommand -and (Test-Path -LiteralPath $PortRegistryScript)) { + try { + $Payload | & $NodeCommand.Source $PortRegistryScript | Out-Null + } catch { + "$(Get-Date -AsUTC -Format o) update-port-registry failed" | Add-Content -LiteralPath (Join-Path $StateDir 'project-ports-errors.log') + } +} + '{"continue": true}' diff --git a/resources/scripts/report-hook-event.sh b/resources/scripts/report-hook-event.sh index c9554b0..cddcb27 100755 --- a/resources/scripts/report-hook-event.sh +++ b/resources/scripts/report-hook-event.sh @@ -3,8 +3,17 @@ set -euo pipefail state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +port_registry_script="$script_dir/update-port-registry.mjs" mkdir -p -- "$state_dir" event_payload="$(cat)" printf '%s\n' "$event_payload" >> "$state_dir/hook-events.log" + +if command -v node >/dev/null 2>&1 && [[ -f "$port_registry_script" ]]; then + if ! printf '%s' "$event_payload" | node "$port_registry_script"; then + printf '%s update-port-registry failed\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$state_dir/project-ports-errors.log" + fi +fi + printf '{"continue": true}\n' diff --git a/resources/scripts/scaffold-discord-oauth-vue3-vite.mjs b/resources/scripts/scaffold-discord-oauth-vue3-vite.mjs new file mode 100755 index 0000000..21deefe --- /dev/null +++ b/resources/scripts/scaffold-discord-oauth-vue3-vite.mjs @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const TEMPLATE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'templates', + 'discord-oauth-vue3-vite', + 'src', + 'server', + 'discord-oauth', +); + +function usage() { + console.error(`Usage: node resources/scripts/scaffold-discord-oauth-vue3-vite.mjs [options] + +Required: + --project-root Target project path. + +Optional: + --mode Default: dry-run + --frontend-origin Default: http://localhost:5173 + --allowlist-discord-ids Default: empty + --scopes Default: identify,email + --force Overwrite generated files. + --help Show this help text. +`); +} + +function parseArgs(argv) { + const options = { + mode: 'dry-run', + frontendOrigin: 'http://localhost:5173', + allowlistDiscordIds: '', + scopes: 'identify,email', + force: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help') { + usage(); + process.exit(0); + } + + if (arg === '--project-root') { + options.projectRoot = argv[index + 1]; + index += 1; + continue; + } + + if (arg === '--mode') { + options.mode = argv[index + 1]; + index += 1; + continue; + } + + if (arg === '--frontend-origin') { + options.frontendOrigin = argv[index + 1]; + index += 1; + continue; + } + + if (arg === '--allowlist-discord-ids') { + options.allowlistDiscordIds = argv[index + 1]; + index += 1; + continue; + } + + if (arg === '--scopes') { + options.scopes = argv[index + 1]; + index += 1; + continue; + } + + if (arg === '--force') { + options.force = true; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + if (!options.projectRoot) { + throw new Error('--project-root is required.'); + } + + if (!['dry-run', 'apply'].includes(options.mode)) { + throw new Error('--mode must be dry-run or apply.'); + } + + options.projectRoot = path.resolve(options.projectRoot); + options.targetRoot = path.join(options.projectRoot, 'src', 'server', 'discord-oauth'); + options.allowlistDiscordIdsJson = JSON.stringify( + options.allowlistDiscordIds + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ); + options.scopesJson = JSON.stringify( + options.scopes + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean), + ); + return options; +} + +function ensureDir(dirPath, mode) { + if (mode === 'dry-run') { + return; + } + fs.mkdirSync(dirPath, { recursive: true }); +} + +function isTextFile(filePath) { + return /\.(?:js|json|md|txt|mjs|sh)$/.test(filePath); +} + +function replacePlaceholders(content, options) { + return content + .replaceAll('__FRONTEND_ORIGIN__', options.frontendOrigin) + .replaceAll('__ALLOWLIST_DISCORD_IDS_JSON__', options.allowlistDiscordIdsJson) + .replaceAll('__SCOPES_JSON__', options.scopesJson); +} + +function walkFiles(rootDir, callback) { + const entries = fs.readdirSync(rootDir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + walkFiles(fullPath, callback); + continue; + } + + callback(fullPath); + } +} + +function scaffold(options) { + if (!fs.existsSync(TEMPLATE_ROOT)) { + throw new Error(`Template root not found: ${TEMPLATE_ROOT}`); + } + + const results = []; + walkFiles(TEMPLATE_ROOT, (sourcePath) => { + const relativePath = path.relative(TEMPLATE_ROOT, sourcePath); + const targetPath = path.join(options.targetRoot, relativePath); + const exists = fs.existsSync(targetPath); + + if (exists && !options.force) { + results.push({ action: 'skipped', filePath: targetPath }); + return; + } + + const action = exists ? 'updated' : 'created'; + if (options.mode === 'apply') { + ensureDir(path.dirname(targetPath), options.mode); + const rawContent = fs.readFileSync(sourcePath); + const content = isTextFile(sourcePath) + ? replacePlaceholders(rawContent.toString('utf8'), options) + : rawContent; + fs.writeFileSync(targetPath, content); + } + + results.push({ action, filePath: targetPath }); + }); + + return results; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + + console.log(`Target bundle: ${path.relative(options.projectRoot, options.targetRoot)}`); + console.log(`Mode: ${options.mode}`); + + const results = scaffold(options); + for (const result of results) { + console.log(`${result.action.toUpperCase()}: ${path.relative(options.projectRoot, result.filePath)}`); + } + + if (options.mode === 'dry-run') { + console.log('Dry-run only. Re-run with --mode apply to write files.'); + } +} + +main(); \ No newline at end of file diff --git a/resources/scripts/scaffold-discord-oauth-vue3-vite.sh b/resources/scripts/scaffold-discord-oauth-vue3-vite.sh new file mode 100755 index 0000000..8d0bf2a --- /dev/null +++ b/resources/scripts/scaffold-discord-oauth-vue3-vite.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +node "$script_dir/scaffold-discord-oauth-vue3-vite.mjs" "$@" \ No newline at end of file diff --git a/resources/scripts/update-port-registry.mjs b/resources/scripts/update-port-registry.mjs new file mode 100644 index 0000000..750deb3 --- /dev/null +++ b/resources/scripts/update-port-registry.mjs @@ -0,0 +1,464 @@ +#!/usr/bin/env node + +// @ts-check + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const defaultStateDir = + process.env.COPILOT_RESOURCES_STATE_DIR || + path.join(os.homedir(), ".copilot-resources-state"); +const machineRegistryName = "project-ports-registry.json"; +const localSnapshotRelativePath = path.join(".local", "project-ports.json"); + +/** + * @typedef {{service: string, protocol: string, port: number, notes: string}} PortEntry + * @typedef {{version: number, projectName: string, projectPath: string, updatedAt: string, lastSeenAt: string, ports: PortEntry[]}} LocalSnapshot + * @typedef {{projectPath: string, projectName: string, localSnapshotPath: string, firstSeenAt: string, lastSeenAt: string, ports: PortEntry[]}} ProjectRecord + * @typedef {{version: number, updatedAt: string, projects: Record, ports: Record>, conflicts: Record}>}} MachineRegistry + */ + +function printUsage() { + console.log( + "Usage: node resources/scripts/update-port-registry.mjs [--report] [--state-dir ] [--project-path ]", + ); +} + +function nowIso() { + return new Date().toISOString(); +} + +/** @param {string} dirPath */ +function ensureDirectory(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +/** + * @template T + * @param {string} filePath + * @param {T} fallbackValue + * @returns {{value: T, parseError: Error | null}} + */ +function safeReadJson(filePath, fallbackValue) { + if (!fs.existsSync(filePath)) { + return { value: fallbackValue, parseError: null }; + } + + try { + return { + value: JSON.parse(fs.readFileSync(filePath, "utf8")), + parseError: null, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + value: fallbackValue, + parseError: new Error( + `Failed to parse JSON file at ${filePath}: ${message}`, + ), + }; + } +} + +/** @param {string} filePath @param {unknown} value */ +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +/** @param {string} value */ +function normalizePath(value) { + return path.resolve(value); +} + +/** @param {unknown} value */ +function normalizePortNumber(value) { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() + ? Number.parseInt(value.trim(), 10) + : NaN; + + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + return null; + } + + return parsed; +} + +/** @param {unknown} rawPorts @returns {PortEntry[]} */ +function normalizePorts(rawPorts) { + if (!Array.isArray(rawPorts)) { + return []; + } + + const dedupe = new Set(); + const ports = []; + + for (const rawEntry of rawPorts) { + if (!rawEntry || typeof rawEntry !== "object") { + continue; + } + + const port = normalizePortNumber(rawEntry.port); + if (port === null) { + continue; + } + + const service = + typeof rawEntry.service === "string" && rawEntry.service.trim() + ? rawEntry.service.trim() + : "default"; + const protocol = + typeof rawEntry.protocol === "string" && rawEntry.protocol.trim() + ? rawEntry.protocol.trim().toLowerCase() + : "tcp"; + + const dedupeKey = `${service}::${protocol}::${port}`; + if (dedupe.has(dedupeKey)) { + continue; + } + + dedupe.add(dedupeKey); + ports.push({ + service, + protocol, + port, + notes: typeof rawEntry.notes === "string" ? rawEntry.notes : "", + }); + } + + ports.sort((left, right) => left.port - right.port || left.service.localeCompare(right.service)); + return ports; +} + +/** @param {any} eventPayload @param {string | null} projectPathOverride */ +function detectProjectContext(eventPayload, projectPathOverride) { + const candidateTimestamp = + typeof eventPayload?.timestamp === "string" && eventPayload.timestamp.trim() + ? eventPayload.timestamp + : nowIso(); + const parsedTimestamp = Number.isNaN(Date.parse(candidateTimestamp)) + ? nowIso() + : new Date(candidateTimestamp).toISOString(); + + if (projectPathOverride) { + const projectPath = normalizePath(projectPathOverride); + return { + projectPath, + projectName: path.basename(projectPath), + timestamp: parsedTimestamp, + payloadCwd: projectPath, + }; + } + + const cwdFromPayload = + typeof eventPayload?.cwd === "string" && eventPayload.cwd.trim() + ? eventPayload.cwd + : null; + const projectPath = normalizePath(cwdFromPayload || process.cwd()); + return { + projectPath, + projectName: path.basename(projectPath), + timestamp: parsedTimestamp, + payloadCwd: cwdFromPayload, + }; +} + +/** @param {{projectName: string, projectPath: string, timestamp: string}} context @returns {LocalSnapshot} */ +function defaultLocalSnapshot({ projectName, projectPath, timestamp }) { + return { + version: 1, + projectName, + projectPath, + updatedAt: timestamp, + lastSeenAt: timestamp, + ports: [], + }; +} + +/** @param {{projectPath: string, projectName: string, timestamp: string}} projectContext */ +function loadAndSyncLocalSnapshot(projectContext) { + const localSnapshotPath = path.join( + projectContext.projectPath, + localSnapshotRelativePath, + ); + ensureDirectory(path.dirname(localSnapshotPath)); + + const { value: localSnapshot, parseError } = safeReadJson( + localSnapshotPath, + defaultLocalSnapshot(projectContext), + ); + + if (parseError) { + throw parseError; + } + + const syncedSnapshot = { + version: 1, + projectName: projectContext.projectName, + projectPath: projectContext.projectPath, + updatedAt: projectContext.timestamp, + lastSeenAt: projectContext.timestamp, + ports: normalizePorts(localSnapshot.ports), + }; + + writeJson(localSnapshotPath, syncedSnapshot); + + return { + localSnapshotPath, + localSnapshot: syncedSnapshot, + }; +} + +/** @param {string} timestamp @returns {MachineRegistry} */ +function defaultRegistry(timestamp) { + return { + version: 1, + updatedAt: timestamp, + projects: {}, + ports: {}, + conflicts: {}, + }; +} + +/** @param {Record} projects */ +function buildPortIndexes(projects) { + /** @type {MachineRegistry["ports"]} */ + const ports = {}; + + for (const project of Object.values(projects)) { + if (!project || typeof project !== "object") { + continue; + } + + for (const entry of normalizePorts(project.ports)) { + const portKey = String(entry.port); + if (!ports[portKey]) { + ports[portKey] = []; + } + + ports[portKey].push({ + projectPath: project.projectPath, + projectName: project.projectName, + service: entry.service, + protocol: entry.protocol, + firstSeenAt: + typeof project.firstSeenAt === "string" ? project.firstSeenAt : null, + lastSeenAt: + typeof project.lastSeenAt === "string" ? project.lastSeenAt : null, + }); + } + } + + for (const entries of Object.values(ports)) { + entries.sort((left, right) => { + const leftSeen = left.firstSeenAt ? Date.parse(left.firstSeenAt) : Number.POSITIVE_INFINITY; + const rightSeen = right.firstSeenAt + ? Date.parse(right.firstSeenAt) + : Number.POSITIVE_INFINITY; + if (leftSeen !== rightSeen) { + return leftSeen - rightSeen; + } + return left.projectPath.localeCompare(right.projectPath); + }); + } + + /** @type {MachineRegistry["conflicts"]} */ + const conflicts = {}; + for (const [port, entries] of Object.entries(ports)) { + if (entries.length < 2) { + continue; + } + + const incumbent = entries[0]; + const recommendedChange = entries[entries.length - 1]; + conflicts[port] = { + incumbentProjectPath: incumbent.projectPath, + incumbentProjectName: incumbent.projectName, + recommendedProjectToChangePath: recommendedChange.projectPath, + recommendedProjectToChangeName: recommendedChange.projectName, + entries, + }; + } + + return { ports, conflicts }; +} + +/** + * @param {{ + * stateDir: string, + * projectContext: {projectPath: string, projectName: string, timestamp: string}, + * localSnapshotPath: string, + * localSnapshot: LocalSnapshot + * }} params + */ +function updateRegistry({ stateDir, projectContext, localSnapshotPath, localSnapshot }) { + ensureDirectory(stateDir); + const registryPath = path.join(stateDir, machineRegistryName); + + const { value: registry, parseError } = safeReadJson( + registryPath, + defaultRegistry(projectContext.timestamp), + ); + + if (parseError) { + throw parseError; + } + + const projects = + registry.projects && typeof registry.projects === "object" + ? registry.projects + : {}; + const existing = + projects[projectContext.projectPath] && + typeof projects[projectContext.projectPath] === "object" + ? projects[projectContext.projectPath] + : null; + + projects[projectContext.projectPath] = { + projectPath: projectContext.projectPath, + projectName: projectContext.projectName, + localSnapshotPath, + firstSeenAt: + existing && typeof existing.firstSeenAt === "string" + ? existing.firstSeenAt + : projectContext.timestamp, + lastSeenAt: projectContext.timestamp, + ports: normalizePorts(localSnapshot.ports), + }; + + const { ports, conflicts } = buildPortIndexes(projects); + const updatedRegistry = { + version: 1, + updatedAt: projectContext.timestamp, + projects, + ports, + conflicts, + }; + + writeJson(registryPath, updatedRegistry); + return { registryPath, registry: updatedRegistry }; +} + +/** @param {string} stateDir @param {string} errorMessage */ +function appendError(stateDir, errorMessage) { + ensureDirectory(stateDir); + const errorLine = `${nowIso()} ${errorMessage}`; + fs.appendFileSync(path.join(stateDir, "project-ports-errors.log"), `${errorLine}\n`, "utf8"); +} + +function readStdin() { + return fs.readFileSync(0, "utf8"); +} + +/** + * @param {string[]} argv + * @returns {{stateDir: string, report: boolean, projectPath: string | null, help: boolean}} + */ +function parseArgs(argv) { + /** @type {{stateDir: string, report: boolean, projectPath: string | null, help: boolean}} */ + const options = { + stateDir: defaultStateDir, + report: false, + projectPath: null, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + continue; + } + if (arg === "--report") { + options.report = true; + continue; + } + if (arg === "--state-dir") { + options.stateDir = normalizePath(argv[index + 1]); + index += 1; + continue; + } + if (arg === "--project-path") { + options.projectPath = argv[index + 1]; + index += 1; + continue; + } + } + + return options; +} + +/** @param {string} stateDir */ +function runReport(stateDir) { + const registryPath = path.join(stateDir, machineRegistryName); + const { value: registry, parseError } = safeReadJson( + registryPath, + defaultRegistry(nowIso()), + ); + + if (parseError) { + throw parseError; + } + + const conflicts = registry.conflicts && typeof registry.conflicts === "object" + ? registry.conflicts + : {}; + + const summary = { + registryPath, + updatedAt: registry.updatedAt || null, + projectCount: + registry.projects && typeof registry.projects === "object" + ? Object.keys(registry.projects).length + : 0, + conflictCount: Object.keys(conflicts).length, + conflicts, + }; + + console.log(JSON.stringify(summary, null, 2)); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printUsage(); + return; + } + + if (options.report) { + runReport(options.stateDir); + return; + } + + let eventPayload = {}; + const stdinContent = readStdin(); + if (stdinContent.trim()) { + try { + eventPayload = JSON.parse(stdinContent); + } catch { + eventPayload = {}; + } + } + + const projectContext = detectProjectContext(eventPayload, options.projectPath); + + const { localSnapshotPath, localSnapshot } = loadAndSyncLocalSnapshot(projectContext); + updateRegistry({ + stateDir: options.stateDir, + projectContext, + localSnapshotPath, + localSnapshot, + }); +} + +try { + main(); +} catch (error) { + appendError(defaultStateDir, error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/resources/scripts/update-port-registry.test.mjs b/resources/scripts/update-port-registry.test.mjs new file mode 100644 index 0000000..7b60201 --- /dev/null +++ b/resources/scripts/update-port-registry.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const scriptPath = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "update-port-registry.mjs", +); + +function runSync({ stateDir, projectPath, payload }) { + return spawnSync( + process.execPath, + [scriptPath, "--state-dir", stateDir, "--project-path", projectPath], + { + input: JSON.stringify(payload), + encoding: "utf8", + }, + ); +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +test("creates local snapshot and machine registry", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-port-registry-")); + const stateDir = path.join(tempDir, "state"); + const projectPath = path.join(tempDir, "workspace-a"); + fs.mkdirSync(projectPath, { recursive: true }); + + const result = runSync({ + stateDir, + projectPath, + payload: { timestamp: "2026-05-19T12:00:00.000Z" }, + }); + + assert.equal(result.status, 0, result.stderr); + + const localSnapshotPath = path.join(projectPath, ".local", "project-ports.json"); + assert.equal(fs.existsSync(localSnapshotPath), true); + + const localSnapshot = readJson(localSnapshotPath); + assert.equal(Array.isArray(localSnapshot.ports), true); + + const registryPath = path.join(stateDir, "project-ports-registry.json"); + const registry = readJson(registryPath); + assert.equal(Object.keys(registry.projects).length, 1); + assert.equal(Object.keys(registry.conflicts).length, 0); +}); + +test("reports conflict and recommends changing newest project", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-port-registry-")); + const stateDir = path.join(tempDir, "state"); + + const projectA = path.join(tempDir, "workspace-a"); + const projectB = path.join(tempDir, "workspace-b"); + fs.mkdirSync(path.join(projectA, ".local"), { recursive: true }); + fs.mkdirSync(path.join(projectB, ".local"), { recursive: true }); + + fs.writeFileSync( + path.join(projectA, ".local", "project-ports.json"), + JSON.stringify( + { + version: 1, + projectName: "workspace-a", + projectPath: projectA, + ports: [{ service: "web", port: 3000 }], + }, + null, + 2, + ), + ); + fs.writeFileSync( + path.join(projectB, ".local", "project-ports.json"), + JSON.stringify( + { + version: 1, + projectName: "workspace-b", + projectPath: projectB, + ports: [{ service: "web", port: 3000 }], + }, + null, + 2, + ), + ); + + let result = runSync({ + stateDir, + projectPath: projectA, + payload: { timestamp: "2026-05-19T12:00:00.000Z" }, + }); + assert.equal(result.status, 0, result.stderr); + + result = runSync({ + stateDir, + projectPath: projectB, + payload: { timestamp: "2026-05-19T13:00:00.000Z" }, + }); + assert.equal(result.status, 0, result.stderr); + + const report = spawnSync( + process.execPath, + [scriptPath, "--state-dir", stateDir, "--report"], + { encoding: "utf8" }, + ); + assert.equal(report.status, 0, report.stderr); + + const parsedReport = JSON.parse(report.stdout); + assert.equal(parsedReport.conflictCount, 1); + assert.equal( + parsedReport.conflicts["3000"].recommendedProjectToChangeName, + "workspace-b", + ); +}); + +test("keeps firstSeenAt stable across re-sync", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-port-registry-")); + const stateDir = path.join(tempDir, "state"); + const projectPath = path.join(tempDir, "workspace-a"); + fs.mkdirSync(path.join(projectPath, ".local"), { recursive: true }); + fs.writeFileSync( + path.join(projectPath, ".local", "project-ports.json"), + JSON.stringify( + { + version: 1, + projectName: "workspace-a", + projectPath, + ports: [{ service: "api", port: 4100 }], + }, + null, + 2, + ), + ); + + let result = runSync({ + stateDir, + projectPath, + payload: { timestamp: "2026-05-19T10:00:00.000Z" }, + }); + assert.equal(result.status, 0, result.stderr); + + result = runSync({ + stateDir, + projectPath, + payload: { timestamp: "2026-05-19T14:00:00.000Z" }, + }); + assert.equal(result.status, 0, result.stderr); + + const registry = readJson(path.join(stateDir, "project-ports-registry.json")); + const project = registry.projects[projectPath]; + assert.equal(project.firstSeenAt, "2026-05-19T10:00:00.000Z"); + assert.equal(project.lastSeenAt, "2026-05-19T14:00:00.000Z"); +}); diff --git a/resources/skills/discord-oauth-vue3-vite/SKILL.md b/resources/skills/discord-oauth-vue3-vite/SKILL.md new file mode 100644 index 0000000..4b5b14b --- /dev/null +++ b/resources/skills/discord-oauth-vue3-vite/SKILL.md @@ -0,0 +1,69 @@ +--- +name: discord-oauth-vue3-vite +description: "Use when scaffolding Discord OAuth into a Vue 3 + Vite app with a server bundle under src/server, PKCE, session handling, and route protection." +argument-hint: "project-root= mode= frontend-origin= allowlist-discord-ids=" +--- + +# Discord OAuth Vue 3 + Vite + +Use this skill when you want a portable, repeatable Discord OAuth setup for a Vue 3 + Vite app and you want the server-side auth bundle kept under `src/server/` instead of a standalone `scripts/` service. + +## Procedure + +### Server bundle + +1. Confirm the target project root and review the generated server bundle path, which defaults to `src/server/discord-oauth`. +2. Confirm the user-owned prerequisites: a Discord application, the authorized redirect URI, client ID, client secret, and any Discord user IDs that should be allowlisted. +3. Run `resources/scripts/scaffold-discord-oauth-vue3-vite.sh` in `--mode dry-run` first when the shared resources repo is available; if the script path is not available in the current workspace, continue with the manual scaffold path instead of stopping. +4. Let the scaffold create the auth bundle under `src/server/` with PKCE state handling, Discord token exchange, profile fetch, allowlist checks, refresh-session rotation, and logout cleanup. +5. Copy the generated `clients.example.json` to `clients.json` and fill in the Discord client credentials and frontend origin values. +6. Set `AUTH_PORT`, `JWT_SECRET`, and the other auth env vars locally. Align the Discord redirect URI with the app callback route (`/oauth/callback?provider=discord`). + +### Client-side wiring + +7. Create `useAuth.js` as a module-level singleton composable under `src/client/src/composables/` using the reference template at `resources/templates/discord-oauth-vue3-vite/src/client/composables/useAuth.js`. The singleton exposes `user`, `features`, `isLoading`, `isLoggedIn`, `checkSession()`, `login()`, `logout()`, and `setSession()`. +8. Create `DiscordAuthWidget` as an organism under `src/client/src/components/organisms/` with a colocated SCSS file and a stories stub, using the reference templates at `resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/`. Update the SCSS `@use` paths to match the target project's style foundation before placing. Both the `.vue` and `.scss` must exist; the stories stub must export `LoggedOut` and `LoggedIn`. +9. Create `OAuthCallbackPage.vue` under `src/client/src/pages/` using the reference template at `resources/templates/discord-oauth-vue3-vite/src/client/pages/OAuthCallbackPage.vue`. Register it on the `/oauth/callback` route with no `meta.requiresAuth` guard. +10. Add a `beforeEach` router guard: call `checkSession()` before any route with `meta.requiresAuth: true` and redirect to `/` if `isLoggedIn` is false. Mark protected routes with `meta: { requiresAuth: true }`. +11. Add a Vite dev proxy entry for `/api/auth` pointing to `http://localhost:` in `vite.config.js`. + +### Runtime and validation + +12. Either run the auth server as its own Node process (add an `auth:dev` script: `node src/server/discord-oauth/server.js`) or mount it into an existing backend entrypoint. +13. Validate the flow: start login, complete the callback, check the session endpoint, refresh the session, and log out. Confirm the allowlist rejects an unapproved Discord account. + +## Outputs + +**Server bundle** (scaffold script writes these): + +- `src/server/discord-oauth/server.js` +- `src/server/discord-oauth/config.js` +- `src/server/discord-oauth/sessionStore.js` +- `src/server/discord-oauth/allowlist.js` +- `src/server/discord-oauth/providers/discord.js` +- `src/server/discord-oauth/lib/oauth/pkce.js` +- `src/server/discord-oauth/lib/oauth/providers.js` +- `src/server/discord-oauth/clients.example.json` +- `src/server/discord-oauth/README.md` + +**Client files** (placed from reference templates; adapt paths per project): + +- `src/client/src/composables/useAuth.js` +- `src/client/src/components/organisms/DiscordAuthWidget.vue` +- `src/client/src/components/organisms/DiscordAuthWidget.scss` +- `src/client/src/components/organisms/DiscordAuthWidget.stories.js` +- `src/client/src/pages/OAuthCallbackPage.vue` + +## Do Not Use + +- Do not use this workflow when the project does not have a Node-side runtime path for auth code under `src/server/`. +- Do not use this workflow when the project uses a hosted auth provider or a managed backend where Discord auth should not live in the repo. +- Do not store secrets in the generated files. + +## Notes + +- This workflow follows the proven pattern: origin-specific client config, PKCE, Discord code exchange, allowlist gating, refresh-token rotation, and cookie cleanup. +- Client template files live in `resources/templates/discord-oauth-vue3-vite/src/client/` and are reference-only. Adapt component paths and SCSS `@use` imports to match the target project's conventions before placing them. +- Projects using atomic design require colocated SCSS and a stories stub for every organism; the `DiscordAuthWidget` templates satisfy this by default. +- Keep the generated bundle generic to Vue 3 + Vite so the same path can be reused in other apps. +- If the project does not already have a server entrypoint, add an `auth:dev` script that runs `node src/server/discord-oauth/server.js`, then configure Vite to proxy `/api/auth` to that port. \ No newline at end of file diff --git a/resources/skills/discord-oauth-vue3-vite/references/env-vars.md b/resources/skills/discord-oauth-vue3-vite/references/env-vars.md new file mode 100644 index 0000000..abfcc7a --- /dev/null +++ b/resources/skills/discord-oauth-vue3-vite/references/env-vars.md @@ -0,0 +1,22 @@ +# Environment Variables + +Set these in the target app or in the app's local env files. + +- `AUTH_PORT` - auth service port, default `8787` +- `NODE_ENV` - enables stricter secret checks outside development +- `JWT_SECRET` - signing secret for access tokens +- `ACCESS_TOKEN_TTL` - access token lifetime, default `15m` +- `REFRESH_TOKEN_TTL_DAYS` - refresh session lifetime, default `30` +- `COOKIE_NAME` - refresh token cookie name, default `gopvp_refresh` +- `COOKIE_SECURE` - set to `true` for HTTPS deployments +- `COOKIE_SAME_SITE` - cookie same-site mode, default `lax` +- `AUTH_CLIENTS_FILE` - optional path to a JSON client map +- `AUTH_CLIENTS_JSON` - optional inline JSON client map +- `FRONTEND_ORIGIN` - legacy single-origin fallback +- `DISCORD_CLIENT_ID` - legacy single-origin fallback +- `DISCORD_CLIENT_SECRET` - legacy single-origin fallback +- `DISCORD_SCOPES` - legacy single-origin fallback, default `identify,email` +- `ALLOWLIST_DISCORD_IDS` - legacy single-origin fallback, comma-separated Discord user IDs +- `DEFAULT_FEATURE_KEYS` - feature flags granted to allowlisted users + +Keep secrets out of committed files and copy them into local environment files instead. \ No newline at end of file diff --git a/resources/skills/discord-oauth-vue3-vite/references/implementation-checklist.md b/resources/skills/discord-oauth-vue3-vite/references/implementation-checklist.md new file mode 100644 index 0000000..4cd1723 --- /dev/null +++ b/resources/skills/discord-oauth-vue3-vite/references/implementation-checklist.md @@ -0,0 +1,14 @@ +# Implementation Checklist + +1. Create the Discord application and add the callback URL used by the app. +2. Generate the `src/server/discord-oauth/` bundle from the scaffold. +3. Copy `clients.example.json` to `clients.json` and fill in credentials. +4. Set `AUTH_PORT`, `JWT_SECRET`, and the other auth env vars locally. +5. Wire the auth server: add an `auth:dev` script or mount it into an existing backend entrypoint. +6. Create `useAuth.js` singleton composable from the reference template; adapt the import path. +7. Create `DiscordAuthWidget` organism (`.vue`, `.scss`, `.stories.js`) from reference templates; update SCSS `@use` paths to match the target project's style foundation. +8. Create `OAuthCallbackPage.vue` from the reference template; register it at `/oauth/callback` with no auth guard. +9. Add `meta: { requiresAuth: true }` to protected routes and a `beforeEach` guard that calls `checkSession()` and redirects to `/` if the user is not logged in. +10. Add Vite dev proxy: `/api/auth` → `http://localhost:`. +11. Test login, callback, session persistence, session refresh, and logout. +12. Confirm the allowlist rejects a Discord account that is not approved. \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.scss b/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.scss new file mode 100644 index 0000000..f09ee0f --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.scss @@ -0,0 +1,172 @@ +// organisms/DiscordAuthWidget.scss +// +// NOTE: Update the three @use paths below to match the target project's style +// foundation before placing this file. Remove any imports that are unused. +// +// @use '/global-color' as color; +// @use '/global-variables' as vars; +// @use '/global-mixins' as mix; +// +// Replace vars.$radius-full, vars.$font-body, vars.$radius-md, vars.$radius-sm, +// vars.$z-topbar, and vars.$shadow-card with the target project's equivalents, +// or convert them to CSS custom properties. + +$discord-blurple: #5865f2; +$discord-blurple-hover: #4752c4; +$avatar-size: 2rem; + +.discord-auth-widget { + position: relative; + display: inline-flex; + align-items: center; +} + +// ── Login button ─────────────────────────────────────────────────────────── + +.discord-auth-widget__login-btn { + display: inline-flex; + align-items: center; + gap: 0.55rem; + padding: 0.45rem 0.9rem; + border: none; + border-radius: 9999px; + background: $discord-blurple; + color: #fff; + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + transition: background 0.15s, transform 0.1s; + + &:hover { + background: $discord-blurple-hover; + } + + &:active { + transform: scale(0.97); + } +} + +.discord-auth-widget__discord-logo { + width: 1.1rem; + height: 1.1rem; + flex-shrink: 0; +} + +// ── Profile trigger ──────────────────────────────────────────────────────── + +.discord-auth-widget__profile { + position: relative; +} + +.discord-auth-widget__profile-btn { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.3rem 0.55rem 0.3rem 0.3rem; + border: 1px solid var(--line-soft); + border-radius: 9999px; + background: var(--bg-surface); + color: var(--text-main); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + + &:hover, + &.is-open { + background: var(--bg-filter); + border-color: var(--line-strong); + } +} + +.discord-auth-widget__avatar { + width: $avatar-size; + height: $avatar-size; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + + &--initials { + display: inline-flex; + align-items: center; + justify-content: center; + background: $discord-blurple; + color: #fff; + font-size: 0.85rem; + font-weight: 700; + } +} + +.discord-auth-widget__display-name { + max-width: 9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.discord-auth-widget__chevron { + width: 0.65rem; + height: 0.65rem; + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.18s; + + .is-open & { + transform: rotate(180deg); + } +} + +// ── Dropdown menu ────────────────────────────────────────────────────────── + +.discord-auth-widget__menu { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + min-width: 9rem; + padding: 0.3rem; + border: 1px solid var(--line-strong); + border-radius: 0.5rem; + background: var(--bg-surface-strong); + box-shadow: var(--shadow-card); + z-index: 100; +} + +.discord-auth-widget__menu-item { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + border: none; + border-radius: 0.25rem; + background: transparent; + color: var(--text-main); + font-size: 0.85rem; + font-weight: 600; + text-align: left; + cursor: pointer; + transition: background 0.12s, color 0.12s; + + &:hover { + background: var(--bg-filter); + } + + &--danger { + color: var(--red); + + &:hover { + background: rgba(243, 67, 83, 0.1); + } + } +} + +// ── Menu transition ──────────────────────────────────────────────────────── + +.discord-menu-enter-active, +.discord-menu-leave-active { + transition: opacity 0.15s, transform 0.15s; +} + +.discord-menu-enter-from, +.discord-menu-leave-to { + opacity: 0; + transform: translateY(-0.3rem); +} diff --git a/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.stories.js b/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.stories.js new file mode 100644 index 0000000..b38bd41 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.stories.js @@ -0,0 +1,9 @@ +import DiscordAuthWidget from './DiscordAuthWidget.vue'; + +export default { + title: 'Organisms/DiscordAuthWidget', + component: DiscordAuthWidget, +}; + +export const LoggedOut = {}; +export const LoggedIn = {}; diff --git a/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.vue b/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.vue new file mode 100644 index 0000000..9ab86f2 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/client/components/organisms/DiscordAuthWidget.vue @@ -0,0 +1,120 @@ + + + + diff --git a/resources/templates/discord-oauth-vue3-vite/src/client/composables/useAuth.js b/resources/templates/discord-oauth-vue3-vite/src/client/composables/useAuth.js new file mode 100644 index 0000000..0097d44 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/client/composables/useAuth.js @@ -0,0 +1,60 @@ +import { ref, computed } from "vue"; + +// Module-level singleton — shared across all composable call sites. +const user = ref(null); +const features = ref([]); +const isLoading = ref(false); +let sessionChecked = false; + +const isLoggedIn = computed(() => !!user.value); + +async function checkSession() { + if (sessionChecked) return; + isLoading.value = true; + try { + const res = await fetch("/api/auth/session", { credentials: "include" }); + if (res.ok) { + const data = await res.json(); + user.value = data.user ?? null; + features.value = data.features ?? []; + } + } catch { + // No session or auth server not reachable — stay logged out. + } finally { + isLoading.value = false; + sessionChecked = true; + } +} + +function login(returnTo = window.location.pathname) { + window.location.href = `/api/auth/discord/start?returnTo=${encodeURIComponent(returnTo)}`; +} + +async function logout() { + try { + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); + } finally { + user.value = null; + features.value = []; + sessionChecked = false; + } +} + +function setSession(data) { + user.value = data.user ?? null; + features.value = data.features ?? []; + sessionChecked = true; +} + +export function useAuth() { + return { + user, + features, + isLoading, + isLoggedIn, + checkSession, + login, + logout, + setSession, + }; +} diff --git a/resources/templates/discord-oauth-vue3-vite/src/client/pages/OAuthCallbackPage.vue b/resources/templates/discord-oauth-vue3-vite/src/client/pages/OAuthCallbackPage.vue new file mode 100644 index 0000000..bdb4af8 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/client/pages/OAuthCallbackPage.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/.env.example b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/.env.example new file mode 100644 index 0000000..65c7bb8 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/.env.example @@ -0,0 +1,9 @@ +AUTH_PORT=8787 +NODE_ENV=development +JWT_SECRET=replace-me +ACCESS_TOKEN_TTL=15m +REFRESH_TOKEN_TTL_DAYS=30 +COOKIE_NAME=gopvp_refresh +COOKIE_SECURE=false +COOKIE_SAME_SITE=lax +AUTH_CLIENTS_FILE=src/server/discord-oauth/clients.json \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/README.md b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/README.md new file mode 100644 index 0000000..8864de5 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/README.md @@ -0,0 +1,39 @@ +# discord-oauth + +This bundle lives under `src/server/discord-oauth` and provides a Discord OAuth auth service with PKCE, session rotation, and allowlist support. + +## Quickstart + +1. Copy `clients.example.json` to `clients.json`. +2. Fill in the Discord client ID, client secret, and allowlist entries. +3. Set `JWT_SECRET` and the other auth env vars. +4. Decide how to run the auth server next: + - If the app already has a Node backend, import or mount this bundle there. + - If the app is frontend-only, add an `auth:dev` script that runs `node src/server/discord-oauth/server.js` and proxy `/api/auth` to that port from Vite. +5. Start the app and test login, callback, session refresh, and logout. + +## Wiring Options + +### Option 1: Separate auth process + +Add a package script such as: + +```json +{ + "scripts": { + "auth:dev": "node src/server/discord-oauth/server.js" + } +} +``` + +Then point your Vite proxy at the auth server port for `/api/auth` requests. + +### Option 2: Existing Node backend + +If the project already has an Express or Node entrypoint, import this bundle there and start it alongside the rest of the backend so the frontend can reach the same `/api/auth/*` routes. + +## Notes + +- The callback route should match the Discord redirect URI. +- Keep secrets out of committed files. +- Replace the placeholder frontend origin and allowlist values before running. \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/allowlist.js b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/allowlist.js new file mode 100644 index 0000000..a69c1e3 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/allowlist.js @@ -0,0 +1,31 @@ +function normalizeDiscordUser(profile) { + return { + id: profile.id, + email: profile.email, + username: profile.username, + displayName: profile.global_name || profile.username, + avatarUrl: profile.avatar + ? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png` + : "", + provider: "discord", + }; +} + +export function resolveEntitlements(profile, authClient) { + const user = normalizeDiscordUser(profile); + const allowed = authClient.allowlistDiscordIds.has(`${user.id || ""}`); + + if (!allowed) { + return { + allowed: false, + user, + features: [], + }; + } + + return { + allowed: true, + user, + features: authClient.defaultFeatureKeys, + }; +} \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/clients.example.json b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/clients.example.json new file mode 100644 index 0000000..994f7ca --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/clients.example.json @@ -0,0 +1,11 @@ +[ + { + "key": "discord-oauth-local", + "frontendOrigin": "__FRONTEND_ORIGIN__", + "discordClientId": "", + "discordClientSecret": "", + "discordScopes": __SCOPES_JSON__, + "allowlistDiscordIds": __ALLOWLIST_DISCORD_IDS_JSON__, + "defaultFeatureKeys": ["feature.auth"] + } +] \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/config.js b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/config.js new file mode 100644 index 0000000..4789ec8 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/config.js @@ -0,0 +1,161 @@ +import "dotenv/config"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function parseCsv(value) { + return `${value || ""}` + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeOrigin(value) { + if (!value) { + return ""; + } + + try { + return new URL(value).origin; + } catch { + return ""; + } +} + +function defaultFeatureKeys() { + return parseCsv( + process.env.DEFAULT_FEATURE_KEYS || + "feature.team-list,feature.bills-pc,feature.team-coach", + ); +} + +function normalizeClient(rawClient) { + const frontendOrigin = normalizeOrigin(rawClient.frontendOrigin); + + return { + key: `${rawClient.key || ""}`, + frontendOrigin, + discordClientId: `${rawClient.discordClientId || ""}`, + discordClientSecret: `${rawClient.discordClientSecret || ""}`, + discordScopes: Array.isArray(rawClient.discordScopes) + ? rawClient.discordScopes + : parseCsv(rawClient.discordScopes || "identify,email"), + allowlistDiscordIds: new Set( + Array.isArray(rawClient.allowlistDiscordIds) + ? rawClient.allowlistDiscordIds.map((entry) => `${entry}`) + : parseCsv(rawClient.allowlistDiscordIds || ""), + ), + defaultFeatureKeys: Array.isArray(rawClient.defaultFeatureKeys) + ? rawClient.defaultFeatureKeys + : parseCsv(rawClient.defaultFeatureKeys || defaultFeatureKeys().join(",")), + }; +} + +function validateClient(client) { + if (!client.key) { + throw new Error("Auth client is missing key."); + } + + if (!client.frontendOrigin) { + throw new Error(`Auth client ${client.key} is missing frontendOrigin.`); + } +} + +function parseClientsFromJson(jsonText) { + if (!jsonText) { + return []; + } + + const parsed = JSON.parse(jsonText); + if (!Array.isArray(parsed)) { + throw new Error("Auth clients config must be an array."); + } + + return parsed.map((entry) => normalizeClient(entry)); +} + +function resolveClientsFilePath(configPath) { + if (!configPath) { + return ""; + } + + if (path.isAbsolute(configPath)) { + return configPath; + } + + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(currentDir, "..", "..", configPath); +} + +function loadClientsFromFile(configPath) { + const resolvedPath = resolveClientsFilePath(configPath.trim()); + const fileText = fs.readFileSync(resolvedPath, "utf8"); + const clients = parseClientsFromJson(fileText); + clients.forEach(validateClient); + return clients; +} + +function loadAuthClients() { + const configPath = process.env.AUTH_CLIENTS_FILE || ""; + const jsonText = process.env.AUTH_CLIENTS_JSON || ""; + + if (jsonText.trim()) { + const clients = parseClientsFromJson(jsonText); + clients.forEach(validateClient); + return clients; + } + + if (configPath.trim()) { + return loadClientsFromFile(configPath); + } + + const localDefaultPath = "src/server/discord-oauth/clients.json"; + const resolvedDefaultPath = resolveClientsFilePath(localDefaultPath); + if (fs.existsSync(resolvedDefaultPath)) { + return loadClientsFromFile(localDefaultPath); + } + + const legacyClient = normalizeClient({ + key: "legacy-local", + frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173", + discordClientId: process.env.DISCORD_CLIENT_ID || "", + discordClientSecret: process.env.DISCORD_CLIENT_SECRET || "", + discordScopes: parseCsv(process.env.DISCORD_SCOPES || "identify,email"), + allowlistDiscordIds: parseCsv(process.env.ALLOWLIST_DISCORD_IDS || ""), + defaultFeatureKeys: defaultFeatureKeys(), + }); + + validateClient(legacyClient); + return [legacyClient]; +} + +const authClients = loadAuthClients(); +const authClientsByOrigin = new Map( + authClients.map((client) => [client.frontendOrigin, client]), +); +const authClientsByKey = new Map(authClients.map((client) => [client.key, client])); +const nodeEnv = process.env.NODE_ENV || "development"; +const jwtSecret = process.env.JWT_SECRET || ""; + +if (nodeEnv !== "development" && !jwtSecret.trim()) { + throw new Error("JWT_SECRET is required when NODE_ENV is not development."); +} + +export const config = { + port: Number(process.env.PORT || 8787), + jwtSecret: jwtSecret || "dev-only-secret-change-me", + accessTokenTtl: process.env.ACCESS_TOKEN_TTL || "15m", + refreshTokenTtlDays: Number(process.env.REFRESH_TOKEN_TTL_DAYS || 30), + cookieName: process.env.COOKIE_NAME || "gopvp_refresh", + cookieSecure: `${process.env.COOKIE_SECURE || "false"}` === "true", + cookieSameSite: process.env.COOKIE_SAME_SITE || "lax", + authClients, +}; + +export function getAuthClientByOrigin(origin) { + return authClientsByOrigin.get(normalizeOrigin(origin)); +} + +export function getAuthClientByKey(clientKey) { + return authClientsByKey.get(`${clientKey || ""}`); +} \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/lib/oauth/pkce.js b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/lib/oauth/pkce.js new file mode 100644 index 0000000..6e1e3f3 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/lib/oauth/pkce.js @@ -0,0 +1,46 @@ +function getCrypto() { + if (globalThis.crypto?.subtle && globalThis.crypto?.getRandomValues) { + return globalThis.crypto; + } + + throw new Error("Web Crypto API is required for PKCE utilities."); +} + +function toBase64Url(bytes) { + let base64; + + if (typeof globalThis.btoa === "function") { + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( + "", + ); + base64 = globalThis.btoa(binary); + } else { + base64 = Buffer.from(bytes).toString("base64"); + } + + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +export function createOAuthState(byteLength = 24) { + const cryptoObject = getCrypto(); + const bytes = new Uint8Array(byteLength); + cryptoObject.getRandomValues(bytes); + return toBase64Url(bytes); +} + +export function createCodeVerifier(byteLength = 64) { + const cryptoObject = getCrypto(); + const bytes = new Uint8Array(byteLength); + cryptoObject.getRandomValues(bytes); + return toBase64Url(bytes); +} + +export async function createCodeChallenge(codeVerifier) { + const cryptoObject = getCrypto(); + const encoder = new TextEncoder(); + const digest = await cryptoObject.subtle.digest( + "SHA-256", + encoder.encode(codeVerifier), + ); + return toBase64Url(new Uint8Array(digest)); +} \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/lib/oauth/providers.js b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/lib/oauth/providers.js new file mode 100644 index 0000000..d7f97b5 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/lib/oauth/providers.js @@ -0,0 +1,26 @@ +function buildAuthUrl(baseUrl, params) { + const url = new URL(baseUrl); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && `${value}`.length > 0) { + url.searchParams.set(key, `${value}`); + } + }); + return url.toString(); +} + +export function buildDiscordAuthorizationUrl(options) { + const scopes = Array.isArray(options.scopes) + ? options.scopes.join(" ") + : options.scopes || "identify email"; + + return buildAuthUrl("https://discord.com/oauth2/authorize", { + client_id: options.clientId, + redirect_uri: options.redirectUri, + response_type: "code", + scope: scopes, + state: options.state, + code_challenge: options.codeChallenge, + code_challenge_method: options.codeChallenge ? "S256" : undefined, + prompt: options.prompt, + }); +} \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/providers/discord.js b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/providers/discord.js new file mode 100644 index 0000000..b93a375 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/providers/discord.js @@ -0,0 +1,46 @@ +export async function exchangeDiscordCode({ + code, + codeVerifier, + clientId, + clientSecret, + redirectUri, +}) { + const body = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + code_verifier: codeVerifier, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }); + + const response = await fetch("https://discord.com/api/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Discord token exchange failed: ${text}`); + } + + return response.json(); +} + +export async function fetchDiscordProfile(accessToken) { + const response = await fetch("https://discord.com/api/users/@me", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Discord profile fetch failed: ${text}`); + } + + return response.json(); +} \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/server.js b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/server.js new file mode 100644 index 0000000..35b4e00 --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/server.js @@ -0,0 +1,304 @@ +import express from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import jwt from "jsonwebtoken"; +import { + buildDiscordAuthorizationUrl, +} from "./lib/oauth/providers.js"; +import { + createCodeChallenge, + createCodeVerifier, + createOAuthState, +} from "./lib/oauth/pkce.js"; +import { config, getAuthClientByKey, getAuthClientByOrigin } from "./config.js"; +import { resolveEntitlements } from "./allowlist.js"; +import { SessionStore } from "./sessionStore.js"; +import { + exchangeDiscordCode, + fetchDiscordProfile, +} from "./providers/discord.js"; + +const app = express(); +const store = new SessionStore({ refreshTokenTtlDays: config.refreshTokenTtlDays }); + +app.use( + cors({ + origin(origin, callback) { + if (!origin) { + callback(null, true); + return; + } + + const authClient = getAuthClientByOrigin(origin); + if (authClient) { + callback(null, true); + return; + } + + callback(new Error("Origin is not allowlisted for auth.")); + }, + credentials: true, + }), +); +app.use(express.json()); +app.use(cookieParser()); + +function getRequestOrigin(req) { + const originHeader = req.get("origin"); + if (originHeader) { + return originHeader; + } + + const refererHeader = req.get("referer"); + if (refererHeader) { + try { + return new URL(refererHeader).origin; + } catch { + // Ignore malformed Referer and continue to host/proto fallback. + } + } + + const proto = req.get("x-forwarded-proto") || req.protocol || "http"; + const host = req.get("x-forwarded-host") || req.get("host") || ""; + + if (!host) { + return ""; + } + + return `${proto}://${host}`; +} + +function buildUserPayload(record) { + return { + user: record.user, + features: record.features, + }; +} + +function getCookieOptions(expiresAt) { + return { + httpOnly: true, + secure: config.cookieSecure, + sameSite: config.cookieSameSite, + path: "/", + expires: new Date(expiresAt), + }; +} + +function signAccessToken(record) { + return jwt.sign( + { + sub: record.user.id, + provider: record.user.provider, + features: record.features, + email: record.user.email || "", + }, + config.jwtSecret, + { + expiresIn: config.accessTokenTtl, + }, + ); +} + +function ensureProvider(provider) { + return provider === "discord"; +} + +app.get("/health", (_req, res) => { + res.json({ ok: true }); +}); + +app.get("/api/auth/:provider/start", async (req, res) => { + const provider = `${req.params.provider || ""}`.toLowerCase(); + if (!ensureProvider(provider)) { + res.status(400).json({ error: "Unsupported provider." }); + return; + } + + const authClient = getAuthClientByOrigin(getRequestOrigin(req)); + if (!authClient) { + res.status(403).json({ error: "Unknown or unauthorized frontend origin." }); + return; + } + + if (!authClient.discordClientId || !authClient.discordClientSecret) { + res.status(503).json({ + error: + "Discord OAuth credentials are not configured for this frontend origin.", + }); + return; + } + + const returnTo = `${req.query.returnTo || "/"}`; + const state = createOAuthState(); + const codeVerifier = createCodeVerifier(); + const codeChallenge = await createCodeChallenge(codeVerifier); + + store.createOAuthState({ + state, + provider, + returnTo, + codeVerifier, + clientKey: authClient.key, + }); + + const redirectUri = `${authClient.frontendOrigin}/oauth/callback?provider=${provider}`; + + const authUrl = buildDiscordAuthorizationUrl({ + clientId: authClient.discordClientId, + redirectUri, + state, + scopes: authClient.discordScopes, + codeChallenge, + prompt: "consent", + }); + + res.redirect(authUrl); +}); + +app.post("/api/auth/callback", async (req, res) => { + const provider = `${req.body?.provider || ""}`.toLowerCase(); + const code = `${req.body?.code || ""}`; + const state = `${req.body?.state || ""}`; + + if (!ensureProvider(provider) || !code || !state) { + res.status(400).json({ error: "Invalid callback payload." }); + return; + } + + const oauthState = store.consumeOAuthState(state); + if (!oauthState || oauthState.provider !== provider) { + res.status(400).json({ error: "OAuth state is invalid or expired." }); + return; + } + + const authClient = getAuthClientByKey(oauthState.clientKey); + if (!authClient) { + res.status(400).json({ error: "Unknown auth client context." }); + return; + } + + if (!authClient.discordClientId || !authClient.discordClientSecret) { + res.status(503).json({ + error: + "Discord OAuth credentials are not configured for this frontend origin.", + }); + return; + } + + try { + const redirectUri = `${authClient.frontendOrigin}/oauth/callback?provider=${provider}`; + const tokenPayload = await exchangeDiscordCode({ + code, + codeVerifier: oauthState.codeVerifier, + clientId: authClient.discordClientId, + clientSecret: authClient.discordClientSecret, + redirectUri, + }); + + const profile = await fetchDiscordProfile(tokenPayload.access_token); + + const entitlement = resolveEntitlements(profile, authClient); + if (!entitlement.allowed) { + res.status(403).json({ error: "Account is not allowlisted." }); + return; + } + + const sessionRecord = { + user: entitlement.user, + features: entitlement.features, + }; + + const refresh = store.createRefreshSession(sessionRecord); + const accessToken = signAccessToken(sessionRecord); + + res.cookie( + config.cookieName, + refresh.refreshToken, + getCookieOptions(refresh.expiresAt), + ); + + res.json({ + ...buildUserPayload(sessionRecord), + accessToken, + returnTo: oauthState.returnTo, + }); + } catch { + res.status(500).json({ error: "Failed to complete OAuth callback." }); + } +}); + +app.get("/api/auth/session", (req, res) => { + const refreshToken = req.cookies[config.cookieName]; + if (!refreshToken) { + res.status(401).json({ error: "No active session." }); + return; + } + + const sessionRecord = store.getRefreshSession(refreshToken); + if (!sessionRecord) { + res.clearCookie(config.cookieName, getCookieOptions(Date.now())); + res.status(401).json({ error: "Session expired." }); + return; + } + + const accessToken = signAccessToken(sessionRecord); + + res.json({ + ...buildUserPayload(sessionRecord), + accessToken, + }); +}); + +app.post("/api/auth/refresh", (req, res) => { + const refreshToken = req.cookies[config.cookieName]; + if (!refreshToken) { + res.status(401).json({ error: "No refresh token." }); + return; + } + + const sessionRecord = store.getRefreshSession(refreshToken); + if (!sessionRecord) { + res.clearCookie(config.cookieName, getCookieOptions(Date.now())); + res.status(401).json({ error: "Session expired." }); + return; + } + + store.revokeRefreshSession(refreshToken); + const refresh = store.createRefreshSession({ + user: sessionRecord.user, + features: sessionRecord.features, + }); + const accessToken = signAccessToken(sessionRecord); + + res.cookie( + config.cookieName, + refresh.refreshToken, + getCookieOptions(refresh.expiresAt), + ); + + res.json({ + ...buildUserPayload(sessionRecord), + accessToken, + }); +}); + +app.post("/api/auth/logout", (req, res) => { + const refreshToken = req.cookies[config.cookieName]; + if (refreshToken) { + store.revokeRefreshSession(refreshToken); + } + + res.clearCookie(config.cookieName, { + httpOnly: true, + secure: config.cookieSecure, + sameSite: config.cookieSameSite, + path: "/", + }); + + res.status(204).send(); +}); + +app.listen(config.port, () => { + console.log(`discord-oauth auth service running on http://localhost:${config.port}`); +}); \ No newline at end of file diff --git a/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/sessionStore.js b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/sessionStore.js new file mode 100644 index 0000000..670f95a --- /dev/null +++ b/resources/templates/discord-oauth-vue3-vite/src/server/discord-oauth/sessionStore.js @@ -0,0 +1,74 @@ +import crypto from "node:crypto"; + +const STATE_TTL_MS = 10 * 60 * 1000; + +function now() { + return Date.now(); +} + +function ttlToMs(days) { + return days * 24 * 60 * 60 * 1000; +} + +export class SessionStore { + constructor(options = {}) { + this.oauthStates = new Map(); + this.refreshSessions = new Map(); + this.refreshTtlMs = ttlToMs(options.refreshTokenTtlDays || 30); + } + + createOAuthState(payload) { + this.oauthStates.set(payload.state, { + ...payload, + createdAt: now(), + }); + } + + consumeOAuthState(state) { + const record = this.oauthStates.get(state); + if (!record) { + return null; + } + + this.oauthStates.delete(state); + + if (now() - record.createdAt > STATE_TTL_MS) { + return null; + } + + return record; + } + + createRefreshSession(payload) { + const refreshToken = crypto.randomBytes(48).toString("base64url"); + const expiresAt = now() + this.refreshTtlMs; + + this.refreshSessions.set(refreshToken, { + ...payload, + expiresAt, + }); + + return { + refreshToken, + expiresAt, + }; + } + + getRefreshSession(refreshToken) { + const record = this.refreshSessions.get(refreshToken); + if (!record) { + return null; + } + + if (record.expiresAt <= now()) { + this.refreshSessions.delete(refreshToken); + return null; + } + + return record; + } + + revokeRefreshSession(refreshToken) { + this.refreshSessions.delete(refreshToken); + } +} \ No newline at end of file