chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@sim/security",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.13",
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"exports": {
|
||||
"./compare": {
|
||||
"types": "./src/compare.ts",
|
||||
"default": "./src/compare.ts"
|
||||
},
|
||||
"./encryption": {
|
||||
"types": "./src/encryption.ts",
|
||||
"default": "./src/encryption.ts"
|
||||
},
|
||||
"./hash": {
|
||||
"types": "./src/hash.ts",
|
||||
"default": "./src/hash.ts"
|
||||
},
|
||||
"./hmac": {
|
||||
"types": "./src/hmac.ts",
|
||||
"default": "./src/hmac.ts"
|
||||
},
|
||||
"./tokens": {
|
||||
"types": "./src/tokens.ts",
|
||||
"default": "./src/tokens.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "biome check --write --unsafe .",
|
||||
"lint:check": "biome check .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@sim/tsconfig": "workspace:*",
|
||||
"@types/node": "24.2.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { safeCompare } from './compare'
|
||||
|
||||
describe('safeCompare', () => {
|
||||
it('returns true for identical strings', () => {
|
||||
expect(safeCompare('abc', 'abc')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for equal-length different strings', () => {
|
||||
expect(safeCompare('abc', 'abd')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for different-length strings without throwing', () => {
|
||||
expect(safeCompare('short', 'longer-value')).toBe(false)
|
||||
expect(safeCompare('', 'a')).toBe(false)
|
||||
expect(safeCompare('a', '')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for two empty strings', () => {
|
||||
expect(safeCompare('', '')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles long inputs', () => {
|
||||
const a = 'x'.repeat(10_000)
|
||||
const b = 'x'.repeat(10_000)
|
||||
expect(safeCompare(a, b)).toBe(true)
|
||||
expect(safeCompare(a, `${b.slice(0, -1)}y`)).toBe(false)
|
||||
})
|
||||
|
||||
it('is case-sensitive', () => {
|
||||
expect(safeCompare('ABC', 'abc')).toBe(false)
|
||||
})
|
||||
|
||||
it('distinguishes hex digests that differ in one nibble', () => {
|
||||
const a = 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'
|
||||
const b = 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff8'
|
||||
expect(safeCompare(a, b)).toBe(false)
|
||||
expect(safeCompare(a, a)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Constant-time string comparison using HMAC-digest wrapping to handle
|
||||
* inputs of differing length. Use for HMAC signatures, API keys, and other
|
||||
* secrets where leaking length or content via timing must be avoided.
|
||||
*/
|
||||
export function safeCompare(a: string, b: string): boolean {
|
||||
const key = 'safeCompare'
|
||||
const ha = createHmac('sha256', key).update(a).digest()
|
||||
const hb = createHmac('sha256', key).update(b).digest()
|
||||
return timingSafeEqual(ha, hb)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decrypt, encrypt } from './encryption'
|
||||
|
||||
const KEY = Buffer.from('0'.repeat(64), 'hex')
|
||||
|
||||
describe('encrypt', () => {
|
||||
it('returns iv:ciphertext:authTag and a 32-char hex IV', async () => {
|
||||
const result = await encrypt('secret', KEY)
|
||||
expect(result.encrypted.split(':')).toHaveLength(3)
|
||||
expect(result.iv).toHaveLength(32)
|
||||
})
|
||||
|
||||
it('produces distinct ciphertexts for the same input', async () => {
|
||||
const a = await encrypt('same', KEY)
|
||||
const b = await encrypt('same', KEY)
|
||||
expect(a.encrypted).not.toBe(b.encrypted)
|
||||
})
|
||||
|
||||
it('rejects keys that are not 32 bytes', async () => {
|
||||
await expect(encrypt('x', Buffer.alloc(16))).rejects.toThrow(/32 bytes/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decrypt', () => {
|
||||
it('round-trips arbitrary UTF-8 input', async () => {
|
||||
const plaintext = 'Hello, !"#$%&\'()*+,-./0123456789:;<=>?@'
|
||||
const { encrypted } = await encrypt(plaintext, KEY)
|
||||
const { decrypted } = await decrypt(encrypted, KEY)
|
||||
expect(decrypted).toBe(plaintext)
|
||||
})
|
||||
|
||||
it('round-trips empty strings', async () => {
|
||||
const { encrypted } = await encrypt('', KEY)
|
||||
const { decrypted } = await decrypt(encrypted, KEY)
|
||||
expect(decrypted).toBe('')
|
||||
})
|
||||
|
||||
it('round-trips long inputs', async () => {
|
||||
const plaintext = 'a'.repeat(10_000)
|
||||
const { encrypted } = await encrypt(plaintext, KEY)
|
||||
const { decrypted } = await decrypt(encrypted, KEY)
|
||||
expect(decrypted).toBe(plaintext)
|
||||
})
|
||||
|
||||
it('throws on malformed input', async () => {
|
||||
await expect(decrypt('invalid', KEY)).rejects.toThrow(
|
||||
'Invalid encrypted value format. Expected "iv:encrypted:authTag"'
|
||||
)
|
||||
await expect(decrypt('part1:part2', KEY)).rejects.toThrow(
|
||||
'Invalid encrypted value format. Expected "iv:encrypted:authTag"'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when ciphertext is tampered', async () => {
|
||||
const { encrypted } = await encrypt('original', KEY)
|
||||
const parts = encrypted.split(':')
|
||||
parts[1] = `deadbeef${parts[1].slice(8)}`
|
||||
await expect(decrypt(parts.join(':'), KEY)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when auth tag is tampered', async () => {
|
||||
const { encrypted } = await encrypt('original', KEY)
|
||||
const parts = encrypted.split(':')
|
||||
parts[2] = '0'.repeat(32)
|
||||
await expect(decrypt(parts.join(':'), KEY)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when decrypted with a different key', async () => {
|
||||
const { encrypted } = await encrypt('original', KEY)
|
||||
const otherKey = randomBytes(32)
|
||||
await expect(decrypt(encrypted, otherKey)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* AES-256-GCM encryption primitive. Produces a self-contained string in the
|
||||
* format `iv:ciphertext:authTag` (all hex-encoded) that can be stored and
|
||||
* later passed directly to {@link decrypt}.
|
||||
*
|
||||
* @param plaintext - UTF-8 string to encrypt
|
||||
* @param key - 32-byte encryption key
|
||||
*/
|
||||
export async function encrypt(
|
||||
plaintext: string,
|
||||
key: Buffer
|
||||
): Promise<{ encrypted: string; iv: string }> {
|
||||
assertKey(key)
|
||||
|
||||
const iv = randomBytes(16)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 })
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
|
||||
const authTag = cipher.getAuthTag()
|
||||
const ivHex = iv.toString('hex')
|
||||
|
||||
return {
|
||||
encrypted: `${ivHex}:${encrypted}:${authTag.toString('hex')}`,
|
||||
iv: ivHex,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM decryption primitive. Expects input produced by {@link encrypt}
|
||||
* in the format `iv:ciphertext:authTag`. Throws when the format is malformed
|
||||
* or when the GCM auth tag does not verify (tampered ciphertext, wrong key).
|
||||
*/
|
||||
export async function decrypt(encryptedValue: string, key: Buffer): Promise<{ decrypted: string }> {
|
||||
assertKey(key)
|
||||
|
||||
const parts = encryptedValue.split(':')
|
||||
if (parts.length < 3) {
|
||||
throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"')
|
||||
}
|
||||
|
||||
const ivHex = parts[0]
|
||||
const authTagHex = parts[parts.length - 1]
|
||||
const encrypted = parts.slice(1, -1).join(':')
|
||||
|
||||
if (!ivHex || !authTagHex) {
|
||||
throw new Error('Invalid encrypted value format. Expected "iv:encrypted:authTag"')
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const authTag = Buffer.from(authTagHex, 'hex')
|
||||
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: 16 })
|
||||
decipher.setAuthTag(authTag)
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
|
||||
return { decrypted }
|
||||
}
|
||||
|
||||
function assertKey(key: Buffer): void {
|
||||
if (key.length !== 32) {
|
||||
throw new Error('Encryption key must be 32 bytes (256 bits)')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sha256Hex } from './hash'
|
||||
|
||||
describe('sha256Hex', () => {
|
||||
it('is deterministic', () => {
|
||||
expect(sha256Hex('hello')).toBe(sha256Hex('hello'))
|
||||
})
|
||||
|
||||
it('returns a 64-char hex digest', () => {
|
||||
expect(sha256Hex('hello')).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('matches the published vector for the empty string', () => {
|
||||
expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
||||
})
|
||||
|
||||
it('differs for different inputs', () => {
|
||||
expect(sha256Hex('a')).not.toBe(sha256Hex('b'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Deterministic SHA-256 digest of a UTF-8 string, hex-encoded. Use for
|
||||
* indexed lookup of sensitive values (e.g. API key hash columns) where the
|
||||
* caller only needs to verify equality without ever reversing the hash.
|
||||
*/
|
||||
export function sha256Hex(input: string): string {
|
||||
return createHash('sha256').update(input, 'utf8').digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { hmacSha256Base64, hmacSha256Hex } from './hmac'
|
||||
|
||||
describe('hmacSha256Hex', () => {
|
||||
it('is deterministic', () => {
|
||||
expect(hmacSha256Hex('body', 'secret')).toBe(hmacSha256Hex('body', 'secret'))
|
||||
})
|
||||
|
||||
it('returns a 64-char hex digest', () => {
|
||||
expect(hmacSha256Hex('body', 'secret')).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('matches RFC 4231 test vector 1', () => {
|
||||
const key = Buffer.from('0b'.repeat(20), 'hex').toString('binary')
|
||||
expect(hmacSha256Hex('Hi There', key)).toBe(
|
||||
'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'
|
||||
)
|
||||
})
|
||||
|
||||
it('differs when body changes', () => {
|
||||
expect(hmacSha256Hex('a', 'k')).not.toBe(hmacSha256Hex('b', 'k'))
|
||||
})
|
||||
|
||||
it('differs when secret changes', () => {
|
||||
expect(hmacSha256Hex('body', 'k1')).not.toBe(hmacSha256Hex('body', 'k2'))
|
||||
})
|
||||
|
||||
it('accepts a Buffer secret and matches the equivalent binary-string secret', () => {
|
||||
const raw = Buffer.from('0b'.repeat(20), 'hex')
|
||||
expect(hmacSha256Hex('Hi There', raw)).toBe(hmacSha256Hex('Hi There', raw.toString('binary')))
|
||||
})
|
||||
})
|
||||
|
||||
describe('hmacSha256Base64', () => {
|
||||
it('is deterministic', () => {
|
||||
expect(hmacSha256Base64('body', 'secret')).toBe(hmacSha256Base64('body', 'secret'))
|
||||
})
|
||||
|
||||
it('returns a base64 digest', () => {
|
||||
expect(hmacSha256Base64('body', 'secret')).toMatch(/^[A-Za-z0-9+/]+=*$/)
|
||||
})
|
||||
|
||||
it('agrees with hex form via Buffer conversion', () => {
|
||||
const hex = hmacSha256Hex('body', 'secret')
|
||||
const b64 = hmacSha256Base64('body', 'secret')
|
||||
expect(Buffer.from(b64, 'base64').toString('hex')).toBe(hex)
|
||||
})
|
||||
|
||||
it('accepts a Buffer secret (Svix / MS-Teams scheme)', () => {
|
||||
const secret = Buffer.from('whsec-decoded-bytes')
|
||||
const hex = hmacSha256Hex('body', secret)
|
||||
const b64 = hmacSha256Base64('body', secret)
|
||||
expect(Buffer.from(b64, 'base64').toString('hex')).toBe(hex)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 of a UTF-8 body using the given secret, hex-encoded. Use for
|
||||
* webhook signature verification where the provider sends a hex digest
|
||||
* (e.g. `X-Signature: <hex>` or `X-Hub-Signature-256: sha256=<hex>`). Pass the
|
||||
* secret as a `Buffer` when the provider's scheme requires base64-decoding
|
||||
* (e.g. Svix-compatible `whsec_...` secrets). Pair with
|
||||
* {@link ../compare | safeCompare} for timing-safe comparison.
|
||||
*/
|
||||
export function hmacSha256Hex(body: string, secret: string | Buffer): string {
|
||||
return createHmac('sha256', secret).update(body, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 of a UTF-8 body using the given secret, base64-encoded. Use for
|
||||
* webhook signature verification where the provider sends a base64 digest
|
||||
* (e.g. Typeform, Microsoft Teams outgoing webhooks). Pass the secret as a
|
||||
* `Buffer` when the provider's scheme requires base64-decoding. Pair with
|
||||
* {@link ../compare | safeCompare} for timing-safe comparison.
|
||||
*/
|
||||
export function hmacSha256Base64(body: string, secret: string | Buffer): string {
|
||||
return createHmac('sha256', secret).update(body, 'utf8').digest('base64')
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateSecureToken } from './tokens'
|
||||
|
||||
describe('generateSecureToken', () => {
|
||||
it('defaults to 24 bytes (32-char base64url)', () => {
|
||||
const token = generateSecureToken()
|
||||
expect(token).toHaveLength(32)
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
it('honors a custom byte length', () => {
|
||||
const token = generateSecureToken(16)
|
||||
expect(token).toHaveLength(22)
|
||||
})
|
||||
|
||||
it('never repeats across 1000 draws', () => {
|
||||
const seen = new Set<string>()
|
||||
for (let i = 0; i < 1000; i++) seen.add(generateSecureToken())
|
||||
expect(seen.size).toBe(1000)
|
||||
})
|
||||
|
||||
it('is URL-safe (no +, /, or = padding)', () => {
|
||||
const token = generateSecureToken(64)
|
||||
expect(token).not.toMatch(/[+/=]/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random token encoded as base64url. The
|
||||
* returned string is URL-safe (no padding, no `+`/`/`) and suitable for use
|
||||
* as an API key body, bearer token, or one-time identifier.
|
||||
*
|
||||
* @param byteLength - Number of random bytes to draw before encoding. Defaults to 24 (~32 chars).
|
||||
*/
|
||||
export function generateSecureToken(byteLength = 24): string {
|
||||
return randomBytes(byteLength).toString('base64url')
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@sim/tsconfig/library.json",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user