Add shared port registry workflow and improve scaffold tooling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
14
resources/instructions/port-registry.instructions.md
Normal file
14
resources/instructions/port-registry.instructions.md
Normal file
@@ -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.
|
||||
19
resources/prompts/scaffold-discord-oauth-vue3-vite.prompt.md
Normal file
19
resources/prompts/scaffold-discord-oauth-vue3-vite.prompt.md
Normal file
@@ -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=<path> mode=<dry-run|apply> frontend-origin=<url> allowlist-discord-ids=<csv>"
|
||||
---
|
||||
|
||||
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`.
|
||||
@@ -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}'
|
||||
|
||||
@@ -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'
|
||||
|
||||
193
resources/scripts/scaffold-discord-oauth-vue3-vite.mjs
Executable file
193
resources/scripts/scaffold-discord-oauth-vue3-vite.mjs
Executable file
@@ -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 <path> Target project path.
|
||||
|
||||
Optional:
|
||||
--mode <dry-run|apply> Default: dry-run
|
||||
--frontend-origin <url> Default: http://localhost:5173
|
||||
--allowlist-discord-ids <csv> Default: empty
|
||||
--scopes <csv> 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();
|
||||
6
resources/scripts/scaffold-discord-oauth-vue3-vite.sh
Executable file
6
resources/scripts/scaffold-discord-oauth-vue3-vite.sh
Executable file
@@ -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" "$@"
|
||||
464
resources/scripts/update-port-registry.mjs
Normal file
464
resources/scripts/update-port-registry.mjs
Normal file
@@ -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<string, ProjectRecord>, ports: Record<string, Array<{projectPath: string, projectName: string, service: string, protocol: string, firstSeenAt: string | null, lastSeenAt: string | null}>>, conflicts: Record<string, {incumbentProjectPath: string, incumbentProjectName: string, recommendedProjectToChangePath: string, recommendedProjectToChangeName: string, entries: Array<{projectPath: string, projectName: string, service: string, protocol: string, firstSeenAt: string | null, lastSeenAt: string | null}>}>}} MachineRegistry
|
||||
*/
|
||||
|
||||
function printUsage() {
|
||||
console.log(
|
||||
"Usage: node resources/scripts/update-port-registry.mjs [--report] [--state-dir <dir>] [--project-path <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<string, ProjectRecord>} 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;
|
||||
}
|
||||
157
resources/scripts/update-port-registry.test.mjs
Normal file
157
resources/scripts/update-port-registry.test.mjs
Normal file
@@ -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");
|
||||
});
|
||||
69
resources/skills/discord-oauth-vue3-vite/SKILL.md
Normal file
69
resources/skills/discord-oauth-vue3-vite/SKILL.md
Normal file
@@ -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=<path> mode=<dry-run|apply> frontend-origin=<url> allowlist-discord-ids=<csv>"
|
||||
---
|
||||
|
||||
# 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 (`<frontendOrigin>/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:<AUTH_PORT>` 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.
|
||||
@@ -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.
|
||||
@@ -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:<AUTH_PORT>`.
|
||||
11. Test login, callback, session persistence, session refresh, and logout.
|
||||
12. Confirm the allowlist rejects a Discord account that is not approved.
|
||||
@@ -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 '<path-to>/global-color' as color;
|
||||
// @use '<path-to>/global-variables' as vars;
|
||||
// @use '<path-to>/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);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import DiscordAuthWidget from './DiscordAuthWidget.vue';
|
||||
|
||||
export default {
|
||||
title: 'Organisms/DiscordAuthWidget',
|
||||
component: DiscordAuthWidget,
|
||||
};
|
||||
|
||||
export const LoggedOut = {};
|
||||
export const LoggedIn = {};
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { onUnmounted, ref, watch } from "vue";
|
||||
|
||||
import { useAuth } from "../../composables/useAuth.js";
|
||||
|
||||
const { isLoading, isLoggedIn, login, logout, user } = useAuth();
|
||||
|
||||
const menuOpen = ref(false);
|
||||
const wrapperRef = ref(null);
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
menuOpen.value = false;
|
||||
logout();
|
||||
}
|
||||
|
||||
function handleDocumentClick(event) {
|
||||
if (wrapperRef.value && !wrapperRef.value.contains(event.target)) {
|
||||
menuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(menuOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener("click", handleDocumentClick);
|
||||
} else {
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", handleDocumentClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isLoading"
|
||||
ref="wrapperRef"
|
||||
class="discord-auth-widget"
|
||||
>
|
||||
<!-- Logged out -->
|
||||
<button
|
||||
v-if="!isLoggedIn"
|
||||
class="discord-auth-widget__login-btn"
|
||||
type="button"
|
||||
@click="login()"
|
||||
>
|
||||
<svg
|
||||
class="discord-auth-widget__discord-logo"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
Sign in with Discord
|
||||
</button>
|
||||
|
||||
<!-- Logged in -->
|
||||
<div v-else class="discord-auth-widget__profile">
|
||||
<button
|
||||
class="discord-auth-widget__profile-btn"
|
||||
:class="{ 'is-open': menuOpen }"
|
||||
type="button"
|
||||
:aria-expanded="menuOpen"
|
||||
aria-haspopup="menu"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<img
|
||||
v-if="user?.avatarUrl"
|
||||
:src="user.avatarUrl"
|
||||
:alt="user.displayName"
|
||||
class="discord-auth-widget__avatar"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="discord-auth-widget__avatar discord-auth-widget__avatar--initials"
|
||||
aria-hidden="true"
|
||||
>{{ (user?.displayName ?? '?')[0].toUpperCase() }}</span>
|
||||
<span class="discord-auth-widget__display-name">{{ user?.displayName }}</span>
|
||||
<svg
|
||||
class="discord-auth-widget__chevron"
|
||||
viewBox="0 0 10 6"
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
>
|
||||
<path d="M1 1l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Transition name="discord-menu">
|
||||
<div
|
||||
v-if="menuOpen"
|
||||
class="discord-auth-widget__menu"
|
||||
role="menu"
|
||||
>
|
||||
<button
|
||||
class="discord-auth-widget__menu-item discord-auth-widget__menu-item--danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
@use './DiscordAuthWidget.scss';
|
||||
</style>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
|
||||
import { useAuth } from "../composables/useAuth.js";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { setSession } = useAuth();
|
||||
|
||||
const error = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const provider = `${route.query.provider || ""}`.toLowerCase();
|
||||
const code = `${route.query.code || ""}`;
|
||||
const state = `${route.query.state || ""}`;
|
||||
|
||||
if (!provider || !code || !state) {
|
||||
error.value = "Missing OAuth callback parameters.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/callback", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider, code, state }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
error.value = data.error || `Auth failed (${res.status}).`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setSession(data);
|
||||
|
||||
const returnTo = `${data.returnTo || "/"}`;
|
||||
router.replace(returnTo.startsWith("/") ? returnTo : "/");
|
||||
} catch {
|
||||
error.value = "Could not reach the auth server. Try again.";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="oauth-callback-page">
|
||||
<p v-if="error" class="oauth-callback-error">{{ error }}</p>
|
||||
<p v-else class="oauth-callback-loading">Completing sign-in…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.oauth-callback-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100dvh;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
color: var(--color-text-muted, #666);
|
||||
}
|
||||
|
||||
.oauth-callback-error {
|
||||
color: var(--color-danger, #c0392b);
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"key": "discord-oauth-local",
|
||||
"frontendOrigin": "__FRONTEND_ORIGIN__",
|
||||
"discordClientId": "",
|
||||
"discordClientSecret": "",
|
||||
"discordScopes": __SCOPES_JSON__,
|
||||
"allowlistDiscordIds": __ALLOWLIST_DISCORD_IDS_JSON__,
|
||||
"defaultFeatureKeys": ["feature.auth"]
|
||||
}
|
||||
]
|
||||
@@ -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 || ""}`);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user