Compare commits

..

2 Commits

33 changed files with 2279 additions and 2 deletions

View File

@@ -22,6 +22,31 @@ install/verify.ps1
Scheduled sync will be added on top of the same update and verify entrypoints. 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 ## Audit
```bash ```bash

View File

@@ -20,6 +20,7 @@ install/bootstrap.ps1
- Generates managed VS Code and Copilot CLI MCP config files from the tracked templates in `config/mcp/` - 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 - 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 `.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 - Writes a local install-state file outside the repository
## Optional Settings ## Optional Settings

View File

@@ -31,6 +31,22 @@ session starts:
The shared hook now invokes the shell script through `bash`, so the session 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. 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 ## Publish Refused To Overwrite
The publish scripts stop on collisions by default. Use a new name or rerun with The publish scripts stop on collisions by default. Use a new name or rerun with

View File

@@ -205,6 +205,16 @@ function parseJsonc(input, label) {
} }
function parseJsoncAst(input, label) { function parseJsoncAst(input, label) {
if (!input.trim()) {
return {
type: "object",
start: 0,
end: 0,
properties: [],
value: {},
};
}
let index = 0; let index = 0;
function fail(message) { function fail(message) {
@@ -780,9 +790,10 @@ function main() {
const allManagedServerNames = new Set(Object.keys(managedConfig[options.serverKey])); const allManagedServerNames = new Set(Object.keys(managedConfig[options.serverKey]));
const desiredServerNames = new Set(Object.keys(managedServers)); const desiredServerNames = new Set(Object.keys(managedServers));
const targetText = fs.existsSync(options.target) const existingTargetText = fs.existsSync(options.target)
? fs.readFileSync(options.target, "utf8") ? fs.readFileSync(options.target, "utf8")
: "{}\n"; : "";
const targetText = existingTargetText.trim() ? existingTargetText : "{}\n";
const targetAst = parseJsoncAst(targetText, options.target); const targetAst = parseJsoncAst(targetText, options.target);
const cleanedTargetText = removeStaleManagedServers( const cleanedTargetText = removeStaleManagedServers(
targetText, targetText,

View File

@@ -203,6 +203,25 @@ test("preserves comments and custom servers while pruning stale managed MCP entr
assert.equal(parsed.servers.gitea, undefined); 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", () => { test("renders optional Gitea config when local overrides are complete", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-")); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-mcp-"));
const targetFile = path.join(tempDir, "mcp-config.json"); const targetFile = path.join(tempDir, "mcp-config.json");

View File

@@ -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' -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-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-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 '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 'prompts link' -Path (Join-Path $VscodeUserDir 'prompts')
Assert-Path -Label 'VS Code MCP config' -Path (Join-Path $VscodeUserDir 'mcp.json') 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') Assert-Path -Label 'Copilot CLI MCP config' -Path (Join-Path $CopilotHome 'mcp-config.json')

View File

@@ -75,6 +75,7 @@ main() {
local local_mcp_overrides_file="$canonical_home/.local/mcp.local.jsonc" 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_file="$canonical_home/resources/hooks/session-audit.json"
local session_start_hook_script="$canonical_home/resources/scripts/report-hook-event.sh" 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 "repo root" "$repo_root"
check_path "canonical home" "$canonical_home" 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" "$session_start_hook_file"
check_path "session start hook script" "$session_start_hook_script" check_path "session start hook script" "$session_start_hook_script"
check_readable_file "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 "session start hook shell" "bash"
check_command "port registry updater runtime" "node"
check_path "prompts link" "$vscode_user_dir/prompts" check_path "prompts link" "$vscode_user_dir/prompts"
check_path "VS Code MCP config" "$vscode_mcp_file" check_path "VS Code MCP config" "$vscode_mcp_file"
check_path "Copilot CLI MCP config" "$copilot_cli_mcp_file" check_path "Copilot CLI MCP config" "$copilot_cli_mcp_file"

View 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.

View 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`.

View File

@@ -5,4 +5,15 @@ New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
$Payload = [Console]::In.ReadToEnd() $Payload = [Console]::In.ReadToEnd()
$Payload | Add-Content -LiteralPath (Join-Path $StateDir 'hook-events.log') $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}' '{"continue": true}'

View File

@@ -3,8 +3,17 @@
set -euo pipefail set -euo pipefail
state_dir="${COPILOT_RESOURCES_STATE_DIR:-$HOME/.copilot-resources-state}" 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" mkdir -p -- "$state_dir"
event_payload="$(cat)" event_payload="$(cat)"
printf '%s\n' "$event_payload" >> "$state_dir/hook-events.log" 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' printf '{"continue": true}\n'

View File

@@ -0,0 +1,205 @@
#!/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();

View 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" "$@"

View File

@@ -0,0 +1,486 @@
#!/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;
}

View File

@@ -0,0 +1,167 @@
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");
});

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -0,0 +1,9 @@
import DiscordAuthWidget from './DiscordAuthWidget.vue';
export default {
title: 'Organisms/DiscordAuthWidget',
component: DiscordAuthWidget,
};
export const LoggedOut = {};
export const LoggedIn = {};

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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.

View File

@@ -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,
};
}

View File

@@ -0,0 +1,11 @@
[
{
"key": "discord-oauth-local",
"frontendOrigin": "__FRONTEND_ORIGIN__",
"discordClientId": "",
"discordClientSecret": "",
"discordScopes": __SCOPES_JSON__,
"allowlistDiscordIds": __ALLOWLIST_DISCORD_IDS_JSON__,
"defaultFeatureKeys": ["feature.auth"]
}
]

View File

@@ -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 || ""}`);
}

View File

@@ -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));
}

View File

@@ -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,
});
}

View File

@@ -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();
}

View File

@@ -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}`);
});

View File

@@ -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);
}
}