feat: cookie Secure conditionnel + sous-commande d'installation de service
Cookie: routes/auth.ts pose `secure` sur le cookie de session quand la requête arrive en HTTPS (x-forwarded-proto), sans trustProxy — durcit le cookie derrière Tailscale Serve sans casser le localhost http. Install: nouveau cli/install.ts + routeur de sous-commandes dans index.ts (install/uninstall/status/serve). Service utilisateur systemd (Linux) ou launchd (macOS), bootstrap du token, --dry-run/--no-enable. Rétrocompat stricte du daemon par défaut (runDaemon extrait). Tests: app.e2e (cookie Secure local vs HTTPS) + cli-install (fonctions pures). 203/203 verts, acceptation P1/P4 vertes. Docs: README.md + README.fr.md (installeur multi-OS, distinction utiliser/cloner, modèle de sécurité durci).
This commit is contained in:
363
packages/server/src/cli/install.ts
Normal file
363
packages/server/src/cli/install.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { parseArgs } from 'node:util';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { openDb } from '../db/index.js';
|
||||
import { AuthService } from '../auth/service.js';
|
||||
|
||||
// Nom de l'unit systemd (Linux) et label launchd par défaut (macOS, surchargeable via --label).
|
||||
const SERVICE_NAME = 'arboretum';
|
||||
const LAUNCHD_LABEL = 'fr.lidge.arboretum';
|
||||
|
||||
export type SupportedPlatform = 'linux' | 'darwin';
|
||||
|
||||
export interface InstallFlags {
|
||||
port?: string | undefined;
|
||||
bind?: string | undefined;
|
||||
allowOrigin: string[];
|
||||
db?: string | undefined;
|
||||
vapidContact?: string | undefined;
|
||||
claudeHome?: string | undefined;
|
||||
binPath?: string | undefined;
|
||||
label: string;
|
||||
dryRun: boolean;
|
||||
noEnable: boolean;
|
||||
}
|
||||
|
||||
// ─── Fonctions pures (génération de contenu / chemins) ────────────────────────────────
|
||||
|
||||
/** macOS (launchd) et Linux (systemd) uniquement ; sinon throw avec un message pédagogique. */
|
||||
export function detectPlatform(platform: NodeJS.Platform = process.platform): SupportedPlatform {
|
||||
if (platform === 'linux' || platform === 'darwin') return platform;
|
||||
throw new Error(
|
||||
`Automatic service installation is supported on Linux (systemd) and macOS (launchd) only.\n` +
|
||||
`On ${platform}, run \`arboretum\` manually or set up your own supervisor.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseInstallArgs(argv: string[]): InstallFlags {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
port: { type: 'string' },
|
||||
bind: { type: 'string' },
|
||||
'allow-origin': { type: 'string', multiple: true },
|
||||
db: { type: 'string' },
|
||||
'vapid-contact': { type: 'string' },
|
||||
'claude-home': { type: 'string' },
|
||||
'bin-path': { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
'no-enable': { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
return {
|
||||
port: values.port,
|
||||
bind: values.bind,
|
||||
allowOrigin: values['allow-origin'] ?? [],
|
||||
db: values.db,
|
||||
vapidContact: values['vapid-contact'],
|
||||
claudeHome: values['claude-home'],
|
||||
binPath: values['bin-path'],
|
||||
label: values.label ?? LAUNCHD_LABEL,
|
||||
dryRun: values['dry-run'] ?? false,
|
||||
noEnable: values['no-enable'] ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags propagés au service : UNIQUEMENT ceux fournis par l'utilisateur (ordre stable,
|
||||
* ExecStart déterministe). On n'ajoute JAMAIS --i-know-this-exposes-a-terminal automatiquement
|
||||
* (cf. modèle de sécurité : un service exposé doit être un choix conscient et explicite).
|
||||
*/
|
||||
export function buildServiceArgs(flags: InstallFlags): string[] {
|
||||
const args: string[] = [];
|
||||
if (flags.port) args.push('--port', flags.port);
|
||||
if (flags.bind) args.push('--bind', flags.bind);
|
||||
for (const origin of flags.allowOrigin) args.push('--allow-origin', origin);
|
||||
if (flags.db) args.push('--db', flags.db);
|
||||
if (flags.vapidContact) args.push('--vapid-contact', flags.vapidContact);
|
||||
if (flags.claudeHome) args.push('--claude-home', flags.claudeHome);
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Le `dist/index.js` réellement installé (depuis dist/cli/install.js). */
|
||||
export function resolveScriptPath(): string {
|
||||
return fileURLToPath(new URL('../index.js', import.meta.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cible exécutable du service. Par défaut node + script (immunisé contre un PATH minimal sous
|
||||
* systemd/launchd) ; --bin-path force le wrapper `arboretum` global (suit les upgrades npm i -g).
|
||||
*/
|
||||
export function resolveBin(flags: Pick<InstallFlags, 'binPath'>): { exec: string; args: string[] } {
|
||||
if (flags.binPath) return { exec: flags.binPath, args: [] };
|
||||
return { exec: process.execPath, args: [resolveScriptPath()] };
|
||||
}
|
||||
|
||||
export function systemdUnitPath(): string {
|
||||
const configHome = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
|
||||
return join(configHome, 'systemd', 'user', `${SERVICE_NAME}.service`);
|
||||
}
|
||||
|
||||
export function launchAgentPlistPath(label: string): string {
|
||||
return join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
||||
}
|
||||
|
||||
export function launchdLogPaths(): { dir: string; out: string; err: string } {
|
||||
const dir = join(homedir(), 'Library', 'Logs', 'arboretum');
|
||||
return { dir, out: join(dir, 'out.log'), err: join(dir, 'err.log') };
|
||||
}
|
||||
|
||||
export function xmlEscape(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// systemd accepte les doubles quotes dans ExecStart ; on ne quote que les tokens à espaces.
|
||||
function quoteIfNeeded(token: string): string {
|
||||
return /\s/.test(token) ? `"${token}"` : token;
|
||||
}
|
||||
|
||||
export function renderSystemdUnit(input: { exec: string; scriptArgs: string[] }): string {
|
||||
const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' ');
|
||||
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
|
||||
return `[Unit]
|
||||
Description=Arboretum — git worktree & Claude Code dashboard
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=${execStart}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=10
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderLaunchAgentPlist(input: {
|
||||
label: string;
|
||||
programArguments: string[];
|
||||
stdoutPath: string;
|
||||
stderrPath: string;
|
||||
}): string {
|
||||
const args = input.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
||||
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${xmlEscape(input.label)}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
${args}
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${xmlEscape(input.stdoutPath)}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${xmlEscape(input.stderrPath)}</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>NODE_ENV</key>
|
||||
<string>production</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
}
|
||||
|
||||
export function printTokenBanner(token: string, url: string): void {
|
||||
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
|
||||
console.log('│ First start — your access token (shown once, store it safely): │');
|
||||
console.log('└──────────────────────────────────────────────────────────────────┘');
|
||||
console.log(`\n ${token}\n`);
|
||||
console.log(` Login at: ${url}/\n`);
|
||||
}
|
||||
|
||||
export function printUsage(version: string): void {
|
||||
console.log(`Arboretum v${version} — git worktree & Claude Code dashboard
|
||||
|
||||
Usage:
|
||||
arboretum [flags] Start the daemon (default)
|
||||
arboretum serve [flags] Start the daemon (explicit alias)
|
||||
arboretum install [flags] Install & start a user service (systemd on Linux, launchd on macOS)
|
||||
arboretum uninstall Stop & remove the user service
|
||||
arboretum status Show the service status
|
||||
arboretum help Show this help
|
||||
|
||||
Daemon flags:
|
||||
--port <n> Port to listen on (default 7317)
|
||||
--bind <addr> Bind address (default 127.0.0.1)
|
||||
--allow-origin <url> Additional allowed Origin (repeatable)
|
||||
--db <path> SQLite database path
|
||||
--vapid-contact <mailto|url> VAPID contact subject for Web Push
|
||||
--i-know-this-exposes-a-terminal Acknowledge a non-loopback bind (avoid — prefer Tailscale Serve)
|
||||
|
||||
Install flags (daemon flags above are propagated to the service):
|
||||
--bin-path <path> Use this binary in the service instead of node + script
|
||||
--label <id> launchd label (macOS only, default ${LAUNCHD_LABEL})
|
||||
--dry-run Print the unit/plist and commands without applying anything
|
||||
--no-enable Write the service file but do not enable/start it
|
||||
`);
|
||||
}
|
||||
|
||||
// ─── Effets de bord (fs + exec) ───────────────────────────────────────────────────────
|
||||
|
||||
function run(cmd: string, args: string[], opts?: { check?: boolean }): number {
|
||||
const res = spawnSync(cmd, args, { stdio: 'inherit' });
|
||||
if (res.error) {
|
||||
if ((res.error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new Error(`Command not found: ${cmd}. Is it installed and on your PATH?`);
|
||||
}
|
||||
throw res.error;
|
||||
}
|
||||
const code = res.status ?? 0;
|
||||
if (opts?.check && code !== 0) {
|
||||
throw new Error(`Command failed (exit ${code}): ${cmd} ${args.join(' ')}`);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap du token avec EXACTEMENT les flags du service (même db). Valide aussi le bind
|
||||
* (garde-fou de loadConfig). Affiche le token une fois, puis ferme la db avant que le service
|
||||
* ne l'ouvre. Skippé en --dry-run par l'appelant.
|
||||
*/
|
||||
function bootstrapToken(serviceArgs: string[]): void {
|
||||
const config = loadConfig(serviceArgs);
|
||||
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
|
||||
const db = openDb(config.dbPath);
|
||||
try {
|
||||
const token = new AuthService(db).ensureBootstrapToken();
|
||||
if (token) printTokenBanner(token, url);
|
||||
else console.log('\nAn access token already exists in this database — manage tokens from Settings.\n');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInstall(argv: string[]): Promise<void> {
|
||||
const platform = detectPlatform();
|
||||
const flags = parseInstallArgs(argv);
|
||||
const serviceArgs = buildServiceArgs(flags);
|
||||
const { exec, args: binArgs } = resolveBin(flags);
|
||||
const scriptArgs = [...binArgs, ...serviceArgs];
|
||||
|
||||
if (platform === 'linux') {
|
||||
const unit = renderSystemdUnit({ exec, scriptArgs });
|
||||
const unitPath = systemdUnitPath();
|
||||
if (flags.dryRun) {
|
||||
console.log(`# ${unitPath}\n${unit}\n# commands:`);
|
||||
console.log('systemctl --user daemon-reload');
|
||||
if (!flags.noEnable) {
|
||||
console.log(`systemctl --user enable --now ${SERVICE_NAME}`);
|
||||
console.log(`loginctl enable-linger ${process.env.USER ?? '$USER'}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
bootstrapToken(serviceArgs);
|
||||
mkdirSync(dirname(unitPath), { recursive: true });
|
||||
writeFileSync(unitPath, unit);
|
||||
console.log(`Wrote ${unitPath}`);
|
||||
run('systemctl', ['--user', 'daemon-reload'], { check: true });
|
||||
if (!flags.noEnable) {
|
||||
run('systemctl', ['--user', 'enable', '--now', SERVICE_NAME], { check: true });
|
||||
// enable-linger best-effort : absent en CI / sans session loginctl, non bloquant.
|
||||
if (run('loginctl', ['enable-linger', process.env.USER ?? '']) !== 0) {
|
||||
console.warn('Warning: could not enable linger — the service may not start at boot.');
|
||||
}
|
||||
}
|
||||
console.log(`\nArboretum service installed. Logs: journalctl --user -u ${SERVICE_NAME} -f`);
|
||||
return;
|
||||
}
|
||||
|
||||
// macOS (launchd)
|
||||
const logs = launchdLogPaths();
|
||||
const programArguments = [exec, ...scriptArgs];
|
||||
const plist = renderLaunchAgentPlist({
|
||||
label: flags.label,
|
||||
programArguments,
|
||||
stdoutPath: logs.out,
|
||||
stderrPath: logs.err,
|
||||
});
|
||||
const plistPath = launchAgentPlistPath(flags.label);
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
const target = `gui/${uid}/${flags.label}`;
|
||||
if (flags.dryRun) {
|
||||
console.log(`# ${plistPath}\n${plist}\n# commands:`);
|
||||
console.log(`launchctl bootout ${target} # best-effort`);
|
||||
if (!flags.noEnable) {
|
||||
console.log(`launchctl bootstrap gui/${uid} ${plistPath}`);
|
||||
console.log(`launchctl enable ${target}`);
|
||||
console.log(`launchctl kickstart -k ${target}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
bootstrapToken(serviceArgs);
|
||||
mkdirSync(dirname(plistPath), { recursive: true });
|
||||
mkdirSync(logs.dir, { recursive: true });
|
||||
writeFileSync(plistPath, plist);
|
||||
console.log(`Wrote ${plistPath}`);
|
||||
if (!flags.noEnable) {
|
||||
run('launchctl', ['bootout', target]); // best-effort : ignore "not loaded" (rend bootstrap idempotent)
|
||||
run('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { check: true });
|
||||
run('launchctl', ['enable', target]);
|
||||
run('launchctl', ['kickstart', '-k', target]);
|
||||
}
|
||||
console.log(`\nArboretum service installed. Logs: ${logs.out}`);
|
||||
}
|
||||
|
||||
export async function runUninstall(argv: string[]): Promise<void> {
|
||||
const platform = detectPlatform();
|
||||
const flags = parseInstallArgs(argv);
|
||||
if (platform === 'linux') {
|
||||
const unitPath = systemdUnitPath();
|
||||
run('systemctl', ['--user', 'disable', '--now', SERVICE_NAME]); // best-effort
|
||||
if (existsSync(unitPath)) {
|
||||
rmSync(unitPath);
|
||||
console.log(`Removed ${unitPath}`);
|
||||
}
|
||||
run('systemctl', ['--user', 'daemon-reload']);
|
||||
console.log('Arboretum service removed.');
|
||||
return;
|
||||
}
|
||||
const plistPath = launchAgentPlistPath(flags.label);
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
run('launchctl', ['bootout', `gui/${uid}/${flags.label}`]); // best-effort
|
||||
if (existsSync(plistPath)) {
|
||||
rmSync(plistPath);
|
||||
console.log(`Removed ${plistPath}`);
|
||||
}
|
||||
console.log('Arboretum service removed.');
|
||||
}
|
||||
|
||||
export async function runStatus(argv: string[]): Promise<void> {
|
||||
const platform = detectPlatform();
|
||||
const flags = parseInstallArgs(argv);
|
||||
if (platform === 'linux') {
|
||||
const code = run('systemctl', ['--user', 'status', SERVICE_NAME, '--no-pager']);
|
||||
console.log(`\nLogs: journalctl --user -u ${SERVICE_NAME} -f`);
|
||||
process.exitCode = code;
|
||||
return;
|
||||
}
|
||||
const uid = process.getuid?.() ?? 0;
|
||||
const code = run('launchctl', ['print', `gui/${uid}/${flags.label}`]);
|
||||
console.log(`\nLogs: ${launchdLogPaths().out}`);
|
||||
process.exitCode = code;
|
||||
}
|
||||
@@ -2,16 +2,17 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig } from './config.js';
|
||||
import { loadConfig, type Config } from './config.js';
|
||||
import { openDb } from './db/index.js';
|
||||
import { buildApp } from './app.js';
|
||||
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
|
||||
export async function runDaemon(config: Config): Promise<void> {
|
||||
const db = openDb(config.dbPath);
|
||||
const { app, auth, manager, discovery } = buildApp(config, db, pkg.version);
|
||||
|
||||
@@ -23,11 +24,7 @@ async function main(): Promise<void> {
|
||||
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
||||
if (bootstrapToken) {
|
||||
// Affiché une seule fois : le hash seul est stocké.
|
||||
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
|
||||
console.log('│ First start — your access token (shown once, store it safely): │');
|
||||
console.log('└──────────────────────────────────────────────────────────────────┘');
|
||||
console.log(`\n ${bootstrapToken}\n`);
|
||||
console.log(` Login at: ${url}/\n`);
|
||||
printTokenBanner(bootstrapToken, url);
|
||||
} else if (config.printToken) {
|
||||
console.log('Tokens are stored hashed and cannot be re-printed. Create a new one from Settings (or reset the db).');
|
||||
}
|
||||
@@ -47,6 +44,36 @@ async function main(): Promise<void> {
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
// Routeur de sous-commandes. On inspecte argv[0] AVANT loadConfig (parseArgs strict throw sur
|
||||
// un positionnel inconnu). Aucune sous-commande (ou un flag en tête) → daemon : rétrocompat stricte
|
||||
// de `arboretum`, `arboretum --port 8080`, `npx @johanleroy/git-arboretum --allow-origin …`.
|
||||
async function main(): Promise<void> {
|
||||
const argv = process.argv.slice(2);
|
||||
const cmd = argv[0];
|
||||
switch (cmd) {
|
||||
case 'install':
|
||||
return runInstall(argv.slice(1));
|
||||
case 'uninstall':
|
||||
return runUninstall(argv.slice(1));
|
||||
case 'status':
|
||||
return runStatus(argv.slice(1));
|
||||
case 'serve':
|
||||
return runDaemon(loadConfig(argv.slice(1)));
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
printUsage(pkg.version);
|
||||
return;
|
||||
default:
|
||||
if (cmd && !cmd.startsWith('-')) {
|
||||
console.error(`Unknown command: ${cmd}\n`);
|
||||
printUsage(pkg.version);
|
||||
process.exit(1);
|
||||
}
|
||||
return runDaemon(loadConfig(argv));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
|
||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||
|
||||
// Tailscale Serve / un reverse-proxy TLS posent x-forwarded-proto. On ne sert jamais
|
||||
// en TLS direct : `secure` n'est posé que derrière un front HTTPS, jamais en localhost http
|
||||
// (sinon le navigateur refuserait le cookie sur http://127.0.0.1 et le login local casserait).
|
||||
function isHttpsRequest(req: FastifyRequest): boolean {
|
||||
const xfp = req.headers['x-forwarded-proto'];
|
||||
const proto = (Array.isArray(xfp) ? xfp[0] : xfp)?.split(',')[0]?.trim();
|
||||
return proto === 'https';
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(
|
||||
app: FastifyInstance,
|
||||
auth: AuthService,
|
||||
@@ -24,6 +33,7 @@ export function registerAuthRoutes(
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: isHttpsRequest(req),
|
||||
maxAge: 30 * 24 * 3600,
|
||||
});
|
||||
const res: LoginResponse = { ok: true, label: ctx.label };
|
||||
@@ -35,8 +45,9 @@ export function registerAuthRoutes(
|
||||
return reply.send(res);
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/logout', async (_req, reply) => {
|
||||
void reply.clearCookie(auth.cookieName, { path: '/' });
|
||||
app.post('/api/v1/auth/logout', async (req, reply) => {
|
||||
// Les attributs doivent matcher ceux posés au login pour que le navigateur efface bien le cookie.
|
||||
void reply.clearCookie(auth.cookieName, { path: '/', secure: isHttpsRequest(req) });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user