feat(core): Refactor SmartAcme core to centralize interest coordination and update dependencies
This commit is contained in:
		@@ -1,5 +1,13 @@
 | 
			
		||||
# Changelog
 | 
			
		||||
 | 
			
		||||
## 2025-05-01 - 7.2.0 - feat(core)
 | 
			
		||||
Refactor SmartAcme core to centralize interest coordination and update dependencies
 | 
			
		||||
 | 
			
		||||
- Moved interest coordination mechanism out of ICertManager implementations and into SmartAcme core
 | 
			
		||||
- Updated certificate managers (MemoryCertManager and MongoCertManager) to remove redundant interestMap handling
 | 
			
		||||
- Upgraded @push.rocks/tapbundle from 6.0.1 to 6.0.3 in package.json
 | 
			
		||||
- Revised readme.plan.md to reflect the new interest coordination approach
 | 
			
		||||
 | 
			
		||||
## 2025-04-30 - 7.1.0 - feat(certmanagers/integration)
 | 
			
		||||
Add optional wipe methods to certificate managers and update integration tests, plus bump tapbundle dependency
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -59,7 +59,7 @@
 | 
			
		||||
    "@git.zone/tsrun": "^1.3.3",
 | 
			
		||||
    "@git.zone/tstest": "^1.0.96",
 | 
			
		||||
    "@push.rocks/qenv": "^6.1.0",
 | 
			
		||||
    "@push.rocks/tapbundle": "^6.0.1",
 | 
			
		||||
    "@push.rocks/tapbundle": "^6.0.3",
 | 
			
		||||
    "@types/node": "^22.15.3"
 | 
			
		||||
  },
 | 
			
		||||
  "files": [
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										18
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@@ -64,8 +64,8 @@ importers:
 | 
			
		||||
        specifier: ^6.1.0
 | 
			
		||||
        version: 6.1.0
 | 
			
		||||
      '@push.rocks/tapbundle':
 | 
			
		||||
        specifier: ^6.0.1
 | 
			
		||||
        version: 6.0.1(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
 | 
			
		||||
        specifier: ^6.0.3
 | 
			
		||||
        version: 6.0.3(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
 | 
			
		||||
      '@types/node':
 | 
			
		||||
        specifier: ^22.15.3
 | 
			
		||||
        version: 22.15.3
 | 
			
		||||
@@ -821,8 +821,8 @@ packages:
 | 
			
		||||
  '@push.rocks/smartexpect@1.6.1':
 | 
			
		||||
    resolution: {integrity: sha512-NFQXEPkGiMNxyvFwKyzDWe3ADYdf8KNvIcV7TGNZZT3uPQtk65te4Q+a1cWErjP/61yE9XdYiQA66QQp+TV9IQ==}
 | 
			
		||||
 | 
			
		||||
  '@push.rocks/smartexpect@2.3.2':
 | 
			
		||||
    resolution: {integrity: sha512-cKRPl8GTU4j0zwiQsq8+NiAzBv2iJ9laoGkjEpTs37XhkHIN/EympenvMkXWE4/2HDAlyQm1ZwIl4NRzPBzXbA==}
 | 
			
		||||
  '@push.rocks/smartexpect@2.4.2':
 | 
			
		||||
    resolution: {integrity: sha512-L+aS1n5rWhf/yOh5R3zPgwycYtDr5FfrDWgasy6ShhN6Zbn/z/AOPbWcF/OpeTmx0XabWB2h5d4xBcCKLl47cQ==}
 | 
			
		||||
 | 
			
		||||
  '@push.rocks/smartfeed@1.0.11':
 | 
			
		||||
    resolution: {integrity: sha512-02uhXxQamgfBo3T12FsAdfyElnpoWuDUb08B2AE60DbIaukVx/7Mi17xwobApY1flNSr5StZDt8N8vxPhBhIXw==}
 | 
			
		||||
@@ -959,8 +959,8 @@ packages:
 | 
			
		||||
  '@push.rocks/tapbundle@5.6.3':
 | 
			
		||||
    resolution: {integrity: sha512-hFzsf59rg1K70i45llj7PCyyCZp7JW19XRR+Q1gge1T0pBN8Wi53aYqP/2qtxdMiNVe2s3ESp6VJZv3sLOMYPQ==}
 | 
			
		||||
 | 
			
		||||
  '@push.rocks/tapbundle@6.0.1':
 | 
			
		||||
    resolution: {integrity: sha512-GeReOjCSF+X+dnHgG+yxl7Tbc9Hk9HKWMqAGLo/B5g8/u4B+V6C+ZA/Sb6Nks8aQlZLm1wXc2ZwxffoYjUHTig==}
 | 
			
		||||
  '@push.rocks/tapbundle@6.0.3':
 | 
			
		||||
    resolution: {integrity: sha512-SuP14V6TPdtd1y1CYTvwTKJdpHa7EzY55NfaaEMxW4oRKvHgJiOiPEiR/IrtL9tSiDMSfrx12waTMgZheYaBug==}
 | 
			
		||||
 | 
			
		||||
  '@push.rocks/taskbuffer@3.1.7':
 | 
			
		||||
    resolution: {integrity: sha512-QktGVJPucqQmW/QNGnscf4FAigT1H7JWKFGFdRuDEaOHKFh9qN+PXG3QY7DtZ4jfXdGLxPN4yAufDuPSAJYFnw==}
 | 
			
		||||
@@ -5830,7 +5830,7 @@ snapshots:
 | 
			
		||||
      '@push.rocks/smartpromise': 4.2.3
 | 
			
		||||
      fast-deep-equal: 3.1.3
 | 
			
		||||
 | 
			
		||||
  '@push.rocks/smartexpect@2.3.2':
 | 
			
		||||
  '@push.rocks/smartexpect@2.4.2':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@push.rocks/smartdelay': 3.0.5
 | 
			
		||||
      '@push.rocks/smartpromise': 4.2.3
 | 
			
		||||
@@ -6240,7 +6240,7 @@ snapshots:
 | 
			
		||||
      - supports-color
 | 
			
		||||
      - utf-8-validate
 | 
			
		||||
 | 
			
		||||
  '@push.rocks/tapbundle@6.0.1(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)':
 | 
			
		||||
  '@push.rocks/tapbundle@6.0.3(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)':
 | 
			
		||||
    dependencies:
 | 
			
		||||
      '@open-wc/testing': 4.0.0
 | 
			
		||||
      '@push.rocks/consolecolor': 2.0.2
 | 
			
		||||
@@ -6248,7 +6248,7 @@ snapshots:
 | 
			
		||||
      '@push.rocks/smartcrypto': 2.0.4
 | 
			
		||||
      '@push.rocks/smartdelay': 3.0.5
 | 
			
		||||
      '@push.rocks/smartenv': 5.0.12
 | 
			
		||||
      '@push.rocks/smartexpect': 2.3.2
 | 
			
		||||
      '@push.rocks/smartexpect': 2.4.2
 | 
			
		||||
      '@push.rocks/smartfile': 11.2.0
 | 
			
		||||
      '@push.rocks/smartjson': 5.0.20
 | 
			
		||||
      '@push.rocks/smartmongo': 2.0.12(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,44 +1,16 @@
 | 
			
		||||
# Plan: Diskless HTTP-01 Handler and Renaming Existing Handler
 | 
			
		||||
# Plan: Move interestMap from certmanager to smartacme core
 | 
			
		||||
 | 
			
		||||
This plan outlines steps to rename the existing filesystem-based HTTP-01 handler to `Http01Webroot`
 | 
			
		||||
and introduce a new diskless (in-memory) HTTP-01 handler for integration with arbitrary HTTP servers
 | 
			
		||||
(e.g., Express).
 | 
			
		||||
## Goal
 | 
			
		||||
- Pull the interest coordination mechanism out of the ICertManager implementations and into the SmartAcme class.
 | 
			
		||||
 | 
			
		||||
## 1. Rename existing handler to Http01Webroot
 | 
			
		||||
- In `ts/handlers/Http01Handler.ts`:
 | 
			
		||||
  - Rename `Http01HandlerOptions` to `Http01WebrootOptions`.
 | 
			
		||||
  - Rename class `Http01Handler` to `Http01Webroot`.
 | 
			
		||||
  - Remove the legacy alias; rename the handler directly.
 | 
			
		||||
  - In `ts/handlers/index.ts`:
 | 
			
		||||
    - Export `Http01Webroot` under its new name.
 | 
			
		||||
    - Remove any `Http01Handler` export.
 | 
			
		||||
    - Update existing tests (e.g., `test.handlers-http01.ts`) to import `Http01Webroot` instead of `Http01Handler`.
 | 
			
		||||
## Steps
 | 
			
		||||
1. Remove `interestMap` from `ICertManager` interface (`ts/interfaces/certmanager.ts`) and its import of `InterestMap`.
 | 
			
		||||
2. Strip out `interestMap` property, initialization, and usage from `MemoryCertManager` and `MongoCertManager` (`ts/certmanagers/*.ts`).
 | 
			
		||||
3. In `Smartacme` class (`ts/smartacme.classes.smartacme.ts`):
 | 
			
		||||
   - Add a private `interestMap: plugins.lik.InterestMap<string, SmartacmeCert>` property.
 | 
			
		||||
   - Initialize it in the constructor: `this.interestMap = new plugins.lik.InterestMap((domain) => domain);`.
 | 
			
		||||
   - Update `getCertificateForDomain()` and any other consumers to reference `this.interestMap` instead of `this.certmanager.interestMap`.
 | 
			
		||||
4. Remove any tests or code that reference the old `interestMap` on `ICertManager` (if any).
 | 
			
		||||
5. Run CI (`pnpm build` and `pnpm test`) and fix any regressions.
 | 
			
		||||
 | 
			
		||||
## 2. Add new diskless (in-memory) HTTP-01 handler
 | 
			
		||||
- Create `ts/handlers/Http01MemoryHandler.ts`:
 | 
			
		||||
  - Implement `IChallengeHandler<{ token: string; keyAuthorization: string; webPath: string }>`, storing challenges in a private `Map<string, string>`.
 | 
			
		||||
  - `prepare()`: add token→keyAuthorization mapping.
 | 
			
		||||
  - `verify()`: no-op.
 | 
			
		||||
  - `cleanup()`: remove mapping.
 | 
			
		||||
  - Add `handleRequest(req, res, next?)` method:
 | 
			
		||||
    - Parse `/.well-known/acme-challenge/:token` from `req.url`.
 | 
			
		||||
    - If token exists, respond with the key authorization and status 200.
 | 
			
		||||
    - If missing and `next` provided, call `next()`, otherwise respond 404.
 | 
			
		||||
- Export `Http01MemoryHandler` in `ts/handlers/index.ts`.
 | 
			
		||||
 | 
			
		||||
## 3. Write tests for Http01MemoryHandler
 | 
			
		||||
- Create `test/test.handlers-http01-memory.ts`:
 | 
			
		||||
  - Use `tap` and `expect` to:
 | 
			
		||||
    1. `prepare()` a challenge.
 | 
			
		||||
    2. Invoke `handleRequest()` with a fake `req`/`res` to confirm 200 and correct body.
 | 
			
		||||
    3. `cleanup()` the challenge.
 | 
			
		||||
    4. Confirm `handleRequest()` now yields 404.
 | 
			
		||||
 | 
			
		||||
## 4. Update documentation
 | 
			
		||||
- Add examples in `readme.md` showing how to use both `Http01Webroot` and the new `Http01MemoryHandler`:
 | 
			
		||||
  - Sample code for Express integration using `handleRequest`.
 | 
			
		||||
 | 
			
		||||
## 5. Build and test
 | 
			
		||||
- Run `pnpm build` and `pnpm test`, ensuring existing tests are updated for `Http01Webroot` and new tests pass.
 | 
			
		||||
 | 
			
		||||
Please review and let me know if this plan makes sense before proceeding with implementation.
 | 
			
		||||
Please review and confirm before we begin the refactor.
 | 
			
		||||
@@ -30,14 +30,17 @@ tap.test('create SmartAcme instance with DNS-01 handler and start', async () =>
 | 
			
		||||
  expect(smartAcmeInstance).toBeInstanceOf(SmartAcme);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('should wipe the certmanager for this test', async () => {
 | 
			
		||||
  await smartAcmeInstance.certmanager.wipe();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
tap.test('get a domain certificate via DNS-01 challenge', async () => {
 | 
			
		||||
  // Replace 'bleu.de' with your test domain if different
 | 
			
		||||
  const domain = 'bleu.de';
 | 
			
		||||
  const cert = await smartAcmeInstance.getCertificateForDomain(domain);
 | 
			
		||||
  console.log(cert);
 | 
			
		||||
  expect(cert).object.toHaveOwnProperty('domainName');
 | 
			
		||||
  expect(cert).toHaveProperty('domainName');
 | 
			
		||||
  expect(cert.domainName).toEqual(domain);
 | 
			
		||||
  expect(cert).object.toHaveOwnProperty('publicKey');
 | 
			
		||||
  expect(cert).toHaveProperty('publicKey');
 | 
			
		||||
  expect(typeof cert.publicKey).toEqual('string');
 | 
			
		||||
  expect(cert.publicKey.length).toBeGreaterThan(0);
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -3,6 +3,6 @@
 | 
			
		||||
 */
 | 
			
		||||
export const commitinfo = {
 | 
			
		||||
  name: '@push.rocks/smartacme',
 | 
			
		||||
  version: '7.1.0',
 | 
			
		||||
  version: '7.2.0',
 | 
			
		||||
  description: 'A TypeScript-based ACME client for LetsEncrypt certificate management with a focus on simplicity and power.'
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								ts/certmanagers/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								ts/certmanagers/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
export * from './memory.js';
 | 
			
		||||
export * from './mongo.js';
 | 
			
		||||
							
								
								
									
										49
									
								
								ts/certmanagers/memory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								ts/certmanagers/memory.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
			
		||||
import * as plugins from '../smartacme.plugins.js';
 | 
			
		||||
import type { ICertManager } from '../interfaces/certmanager.js';
 | 
			
		||||
import { SmartacmeCert } from '../smartacme.classes.cert.js';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * In-memory certificate manager for mongoless mode.
 | 
			
		||||
 * Stores certificates in memory only and does not connect to MongoDB.
 | 
			
		||||
 */
 | 
			
		||||
export class MemoryCertManager implements ICertManager {
 | 
			
		||||
  public interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
 | 
			
		||||
  private certs: Map<string, SmartacmeCert> = new Map();
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.interestMap = new plugins.lik.InterestMap((domain) => domain);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async init(): Promise<void> {
 | 
			
		||||
    // no-op for in-memory store
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async retrieveCertificate(domainName: string): Promise<SmartacmeCert | null> {
 | 
			
		||||
    return this.certs.get(domainName) ?? null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async storeCertificate(cert: SmartacmeCert): Promise<void> {
 | 
			
		||||
    this.certs.set(cert.domainName, cert);
 | 
			
		||||
    const interest = this.interestMap.findInterest(cert.domainName);
 | 
			
		||||
    if (interest) {
 | 
			
		||||
      interest.fullfillInterest(cert);
 | 
			
		||||
      interest.markLost();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async deleteCertificate(domainName: string): Promise<void> {
 | 
			
		||||
    this.certs.delete(domainName);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async close(): Promise<void> {
 | 
			
		||||
    // no-op
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * Wipe all certificates from the in-memory store (for testing)
 | 
			
		||||
   */
 | 
			
		||||
  public async wipe(): Promise<void> {
 | 
			
		||||
    this.certs.clear();
 | 
			
		||||
    // reset interest map
 | 
			
		||||
    this.interestMap = new plugins.lik.InterestMap((domain) => domain);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -1,52 +1,6 @@
 | 
			
		||||
import * as plugins from './smartacme.plugins.js';
 | 
			
		||||
import type { ICertManager } from './interfaces/certmanager.js';
 | 
			
		||||
import { SmartacmeCert } from './smartacme.classes.cert.js';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * In-memory certificate manager for mongoless mode.
 | 
			
		||||
 * Stores certificates in memory only and does not connect to MongoDB.
 | 
			
		||||
 */
 | 
			
		||||
export class MemoryCertManager implements ICertManager {
 | 
			
		||||
  public interestMap: plugins.lik.InterestMap<string, SmartacmeCert>;
 | 
			
		||||
  private certs: Map<string, SmartacmeCert> = new Map();
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.interestMap = new plugins.lik.InterestMap((domain) => domain);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async init(): Promise<void> {
 | 
			
		||||
    // no-op for in-memory store
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async retrieveCertificate(domainName: string): Promise<SmartacmeCert | null> {
 | 
			
		||||
    return this.certs.get(domainName) ?? null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async storeCertificate(cert: SmartacmeCert): Promise<void> {
 | 
			
		||||
    this.certs.set(cert.domainName, cert);
 | 
			
		||||
    const interest = this.interestMap.findInterest(cert.domainName);
 | 
			
		||||
    if (interest) {
 | 
			
		||||
      interest.fullfillInterest(cert);
 | 
			
		||||
      interest.markLost();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async deleteCertificate(domainName: string): Promise<void> {
 | 
			
		||||
    this.certs.delete(domainName);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async close(): Promise<void> {
 | 
			
		||||
    // no-op
 | 
			
		||||
  }
 | 
			
		||||
  /**
 | 
			
		||||
   * Wipe all certificates from the in-memory store (for testing)
 | 
			
		||||
   */
 | 
			
		||||
  public async wipe(): Promise<void> {
 | 
			
		||||
    this.certs.clear();
 | 
			
		||||
    // reset interest map
 | 
			
		||||
    this.interestMap = new plugins.lik.InterestMap((domain) => domain);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
import * as plugins from '../smartacme.plugins.js';
 | 
			
		||||
import type { ICertManager } from '../interfaces/certmanager.js';
 | 
			
		||||
import { SmartacmeCert } from '../smartacme.classes.cert.js';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * MongoDB-backed certificate manager using EasyStore from smartdata.
 | 
			
		||||
@@ -104,4 +58,4 @@ export class MongoCertManager implements ICertManager {
 | 
			
		||||
    // reset interest map
 | 
			
		||||
    this.interestMap = new plugins.lik.InterestMap((domain) => domain);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
export * from './smartacme.classes.smartacme.js';
 | 
			
		||||
export { SmartacmeCert as Cert } from './smartacme.classes.cert.js';
 | 
			
		||||
export type { ICertManager } from './interfaces/certmanager.js';
 | 
			
		||||
export { MemoryCertManager, MongoCertManager } from './certmanagers.js';
 | 
			
		||||
export { MemoryCertManager, MongoCertManager } from './certmanagers/index.js';
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import type { InterestMap } from '@push.rocks/lik';
 | 
			
		||||
import type { SmartacmeCert } from '../smartacme.classes.cert.js';
 | 
			
		||||
 | 
			
		||||
// (ICertRecord removed; use SmartacmeCert directly)
 | 
			
		||||
@@ -9,10 +8,6 @@ import type { SmartacmeCert } from '../smartacme.classes.cert.js';
 | 
			
		||||
 * file-based, Redis, etc.).
 | 
			
		||||
 */
 | 
			
		||||
export interface ICertManager {
 | 
			
		||||
  /**
 | 
			
		||||
   * Map for coordinating concurrent certificate requests.
 | 
			
		||||
   */
 | 
			
		||||
  interestMap: InterestMap<string, SmartacmeCert>;
 | 
			
		||||
  /**
 | 
			
		||||
   * Initialize the store (e.g., connect to database).
 | 
			
		||||
   */
 | 
			
		||||
@@ -37,5 +32,5 @@ export interface ICertManager {
 | 
			
		||||
  /**
 | 
			
		||||
   * Optional: wipe all stored certificates (e.g., for integration testing)
 | 
			
		||||
   */
 | 
			
		||||
  wipe?(): Promise<void>;
 | 
			
		||||
  wipe(): Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
@@ -63,7 +63,7 @@ export class SmartAcme {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  // certificate manager for persistence (implements ICertManager)
 | 
			
		||||
  private certmanager: ICertManager;
 | 
			
		||||
  public certmanager: ICertManager;
 | 
			
		||||
  private certmatcher: SmartacmeCertMatcher;
 | 
			
		||||
  // retry/backoff configuration (resolved with defaults)
 | 
			
		||||
  private retryOptions: { retries: number; factor: number; minTimeoutMs: number; maxTimeoutMs: number };
 | 
			
		||||
@@ -116,11 +116,6 @@ export class SmartAcme {
 | 
			
		||||
    }
 | 
			
		||||
    this.certmanager = this.options.certManager;
 | 
			
		||||
    await this.certmanager.init();
 | 
			
		||||
    // For integration environment, clear any existing certificates to avoid stale entries
 | 
			
		||||
    if (this.options.environment === 'integration' && typeof (this.certmanager as any).wipe === 'function') {
 | 
			
		||||
      // this.logger.log('warn', 'Wiping existing certificates for integration environment');
 | 
			
		||||
      // await (this.certmanager as any).wipe();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // CertMatcher
 | 
			
		||||
    this.certmatcher = new SmartacmeCertMatcher();
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user