Initial Node.js library and CLI

This commit is contained in:
Sergey Chernov 2026-08-19 14:07:16 +04:00
commit dd74209ed1
14 changed files with 1343 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
*.tgz
.DS_Store
coverage/

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 sergeych
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

108
README.md Normal file
View File

@ -0,0 +1,108 @@
# Crypstie
Create and open end-to-end encrypted Crypstie links from Node.js or a shell.
The plaintext and the 256-bit key never reach the Crypstie server: it stores
only an authenticated ciphertext, while the key stays in the URL fragment.
Compatible with links created by the original Kotlin/JS client at
`crypstie.com`. No Kotlin runtime is required.
## Install
```shell
npm install crypstie
```
For global CLI use:
```shell
npm install --global crypstie
```
Requires Node.js 18 or newer.
## CLI
Create a reusable link from stdin or a file:
```shell
printf 'secret text' | crypstie create
crypstie create ./document.txt --days 7
```
Create a burn-on-read link:
```shell
crypstie create ./secret.txt --burn
```
Open a link. Quote it so the shell does not interpret `#`:
```shell
crypstie open 'https://crypstie.com/_id#key'
crypstie open "$CRYPSTIE_URL" --output ./document.bin
crypstie open "$CRYPSTIE_URL" --json
```
`put`/`encrypt` and `get`/`decrypt` are aliases for `create` and `open`.
## Node.js API
```js
const { createCrypstie, openCrypstie } = require('crypstie');
const created = await createCrypstie('secret text', {
burnOnShow: false,
deleteAt: new Date(Date.now() + 7 * 86400_000),
});
console.log(created.url);
const opened = await openCrypstie(created.url);
console.log(opened.text); // UTF-8 convenience view
console.log(opened.data); // Uint8Array with exact bytes
```
Both functions accept an optional `fetch` implementation and `AbortSignal`.
`createCrypstie` also accepts `rootUrl`, UI metadata fields, and binary input.
`openCrypstie` accepts optional `profileIds` for compatibility with owner-aware
legacy links.
## Security model
- Encryption is local AES-256-CTR with Encrypt-then-Authenticate and
SHA-256-based HMAC, implemented by `unicrypto`.
- The encrypted record uses the BOSS wire format.
- The URL fragment is removed locally and is never included in the HTTP request.
- Anyone possessing the complete URL can decrypt the content. Treat the URL as
a secret and avoid placing it in logs.
- `--burn` is enforced by the server. Concurrent first reads are subject to the
guarantees of the deployed server implementation.
## AI agents
The npm package includes a compact skill at `skill/crypstie/SKILL.md`. Point an
agent's skill loader at that directory, or copy the `skill/crypstie` folder into
its skills directory. The skill uses the CLI and keeps secret links out of its
written responses whenever possible.
## Development
```shell
npm ci
npm test
npm pack --dry-run
```
Live read compatibility test:
```shell
CRYPSTIE_TEST_URL='https://crypstie.com/_id#key' npm test
```
Live create/read test (writes one expiring test record):
```shell
CRYPSTIE_LIVE_WRITE=1 npm test
```
Russian documentation: [docs/README.ru.md](docs/README.ru.md).

64
docs/README.ru.md Normal file
View File

@ -0,0 +1,64 @@
# Crypstie для Node.js
Пакет создаёт и открывает зашифрованные ссылки Crypstie без браузера и Kotlin.
Он совместим со старыми ссылками `crypstie.com`.
## Установка
```shell
npm install --global crypstie
```
Требуется Node.js 18 или новее.
## Командная строка
Создать многоразовую ссылку:
```shell
printf 'секретный текст' | crypstie create
crypstie create ./document.txt --days 7
```
Создать ссылку, удаляемую после чтения:
```shell
crypstie create ./secret.txt --burn
```
Прочитать крипстю:
```shell
crypstie open 'https://crypstie.com/_id#key'
crypstie open "$CRYPSTIE_URL" --output ./document.bin
crypstie open "$CRYPSTIE_URL" --json
```
Ссылку следует заключать в кавычки: иначе shell воспримет `#` как начало
комментария. Полная ссылка является секретом — любой получивший её сможет
расшифровать содержимое.
## Программный API
```js
const { createCrypstie, openCrypstie } = require('crypstie');
const created = await createCrypstie('секрет', {
deleteAt: new Date(Date.now() + 7 * 86400_000),
burnOnShow: false,
});
const opened = await openCrypstie(created.url);
console.log(opened.text);
```
Точные исходные байты находятся в `opened.data`, а `opened.text` является их
представлением в UTF-8.
## Модель безопасности
- Случайный 256-битный ключ создаётся локально.
- `unicrypto` использует AES-256-CTR и Encrypt-then-Authenticate с HMAC/SHA-256.
- Сервер получает только BOSS-контейнер с зашифрованными данными.
- Ключ находится после `#` и не передаётся в HTTP-запросе.
- Не помещайте полные ссылки в публичные логи, задачи и сообщения.

485
package-lock.json generated Normal file
View File

@ -0,0 +1,485 @@
{
"name": "crypstie",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crypstie",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"unicrypto": "^1.14.1"
},
"bin": {
"crypstie": "src/cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ansi-styles": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/brorand": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
"integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==",
"license": "MIT"
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
"supports-color": "^5.3.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
}
},
"node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/diffie-hellman": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.1.0",
"miller-rabin": "^4.0.0",
"randombytes": "^2.0.0"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/fastestsmallesttextencoderdecoder": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz",
"integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==",
"license": "CC0-1.0"
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gently-copy": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/gently-copy/-/gently-copy-3.2.0.tgz",
"integrity": "sha512-IBLU4rCffg0Dvq3/7KyiPionCCdEdKnyfe94c00C8+VbgzIS2J9L2jHdLchG9sn8lDqBGzbvfuYVZB/ZlffS7g==",
"license": "MIT",
"dependencies": {
"chalk": "^2.4.2",
"shelljs": "^0.8.3"
},
"engines": {
"node": ">= 4"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/interpret": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz",
"integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsbn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"license": "MIT"
},
"node_modules/miller-rabin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
"integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"brorand": "^1.0.1"
},
"bin": {
"miller-rabin": "bin/miller-rabin"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/rechoir": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz",
"integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==",
"dependencies": {
"resolve": "^1.1.6"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/shelljs": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz",
"integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==",
"license": "BSD-3-Clause",
"dependencies": {
"glob": "^7.0.0",
"interpret": "^1.0.0",
"rechoir": "^0.6.2"
},
"bin": {
"shjs": "bin/shjs"
},
"engines": {
"node": ">=4"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"license": "MIT",
"dependencies": {
"has-flag": "^3.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/unicrypto": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/unicrypto/-/unicrypto-1.14.1.tgz",
"integrity": "sha512-5hS1hDQ6QYIysEyTYuy73C3c5h8kmPTkBp4mJ3nPrI5tv7VZOIMWb0heUTc/EQ2x0VAq7WmhKEwXNKl6pOIBsg==",
"hasInstallScript": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
"dependencies": {
"buffer": "^5.4.2",
"diffie-hellman": "^5.0.3",
"fastestsmallesttextencoderdecoder": "^1.0.14",
"gently-copy": "^3.2.0",
"jsbn": "^1.1.0",
"randombytes": "^2.1.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "crypstie",
"version": "0.1.0",
"description": "Create and open end-to-end encrypted Crypstie links from Node.js and the command line",
"author": "sergeych",
"type": "commonjs",
"main": "src/index.js",
"exports": "./src/index.js",
"bin": {
"crypstie": "./src/cli.js"
},
"files": [
"src/",
"skill/",
"docs/",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node --test"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"unicrypto": "^1.14.1"
},
"keywords": [
"encryption",
"pastebin",
"cli",
"boss",
"unicrypto"
],
"license": "MIT"
}

View File

@ -0,0 +1,45 @@
'use strict';
const { Boss, SymmetricKey } = require('unicrypto');
const { initializeCrypto, openCrypstie } = require('../src');
async function main() {
await initializeCrypto();
const key = new SymmetricKey();
const text = `Crypstie Node compatibility fixture ${new Date().toISOString()}`;
const record = {
guid: '',
encryptedData: key.etaEncryptSync(new TextEncoder().encode(text)),
deleteAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
burnOnShow: false,
showWide: false,
syntaxHighlighting: null,
editable: false,
ownerHandle: null,
};
const form = new FormData();
form.append('data', new Blob([Boss.dump(record)]), 'crypstie.boss');
const response = await fetch('https://crypstie.com/api/crypstie', {
method: 'POST',
body: form,
});
if (!response.ok) throw new Error(`create failed: HTTP ${response.status}`);
const id = await response.text();
const encodedKey = Buffer.from(key.pack())
.toString('base64')
.replaceAll('+', '.')
.replaceAll('/', '_')
.replace(/=+$/, '');
const url = `https://crypstie.com/_${id}#${encodedKey}`;
const opened = await openCrypstie(url);
if (opened.text !== text) throw new Error('live round trip returned different plaintext');
console.log(url);
console.log(opened.text);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

51
skill/crypstie/SKILL.md Normal file
View File

@ -0,0 +1,51 @@
---
name: crypstie
description: Create and open end-to-end encrypted Crypstie links with the crypstie npm CLI. Use when an agent must securely exchange text or files through crypstie.com, decrypt an existing Crypstie URL without a browser, create reusable or burn-on-read links, or automate Crypstie from a shell.
---
# Crypstie
Use the `crypstie` CLI. Install it with `npm install --global crypstie` if the
command is unavailable.
## Open a link
Quote URLs because they contain `#`:
```shell
crypstie open "$CRYPSTIE_URL"
```
Write binary content directly to a file:
```shell
crypstie open "$CRYPSTIE_URL" --output ./result.bin
```
Use `--json` only when metadata or Base64 data is required.
## Create a link
Pipe text without making a temporary plaintext file:
```shell
printf '%s' "$CONTENT" | crypstie create
```
Create from a file and set lifetime:
```shell
crypstie create ./input.bin --days 7
```
Add `--burn` only when the user requests deletion after reading.
## Handle secrets
- Treat the complete URL as a secret: anyone holding it can decrypt the data.
- Never omit the fragment after `#`; it contains the local decryption key.
- Avoid repeating a full URL in explanations, logs, issues, or source files.
- Return a newly created URL only to the requesting user.
- Prefer environment variables when opening links in automation.
- Remember that Crypstie protects stored content, not endpoints where plaintext
is read or written.

View File

@ -0,0 +1,4 @@
interface:
display_name: "Crypstie"
short_description: "Create and open encrypted Crypstie links"
default_prompt: "Use $crypstie to securely create or open a Crypstie link."

136
src/cli.js Executable file
View File

@ -0,0 +1,136 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs/promises');
const process = require('node:process');
const { createCrypstie, openCrypstie } = require('./index');
const pkg = require('../package.json');
const HELP = `crypstie ${pkg.version}
Create and open end-to-end encrypted Crypstie links.
Usage:
crypstie create [file|-] [options]
crypstie open <url> [options]
Aliases:
put, encrypt create
get, decrypt open
Create options:
--burn Delete after the first non-owner read
--days <number> Lifetime in days (default: 90)
--expires <ISO date> Exact expiration time
--base-url <url> Service URL (default: https://crypstie.com)
Open options:
-o, --output <file> Write plaintext bytes to a file (default: stdout)
--json Print metadata and Base64 data as JSON
General:
-h, --help Show help
-v, --version Show version
Examples:
printf 'secret' | crypstie create
crypstie create ./document.txt --days 7
crypstie open 'https://crypstie.com/_id#key'
crypstie open "$CRYPSTIE_URL" -o ./document.bin
`;
async function main(argv = process.argv.slice(2)) {
if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) {
process.stdout.write(HELP);
return;
}
if (argv.includes('--version') || argv.includes('-v')) {
process.stdout.write(`${pkg.version}\n`);
return;
}
const command = argv.shift();
if (['create', 'put', 'encrypt'].includes(command)) return createCommand(argv);
if (['open', 'get', 'decrypt'].includes(command)) return openCommand(argv);
throw new Error(`Unknown command: ${command}. Run crypstie --help.`);
}
async function createCommand(argv) {
const args = parseOptions(argv, new Set(['burn']), new Set(['days', 'expires', 'base-url']));
if (args.positionals.length > 1) throw new Error('create accepts at most one input file');
if (args.values.days && args.values.expires) throw new Error('use either --days or --expires');
const days = args.values.days === undefined ? 90 : Number(args.values.days);
if (!Number.isFinite(days) || days <= 0) throw new Error('--days must be a positive number');
const deleteAt = args.values.expires
? new Date(args.values.expires)
: new Date(Date.now() + days * 24 * 60 * 60 * 1000);
const data = await readInput(args.positionals[0]);
const result = await createCrypstie(data, {
burnOnShow: args.flags.has('burn'),
deleteAt,
rootUrl: args.values['base-url'],
});
process.stdout.write(`${result.url}\n`);
}
async function openCommand(argv) {
const args = parseOptions(argv, new Set(['json']), new Set(['output', 'o']));
if (args.positionals.length !== 1) throw new Error('open requires exactly one Crypstie URL');
const result = await openCrypstie(args.positionals[0]);
const output = args.values.output ?? args.values.o;
if (args.flags.has('json')) {
const json = {
...result,
data: Buffer.from(result.data).toString('base64'),
deleteAt: result.deleteAt?.toISOString() ?? null,
};
delete json.text;
await writeOutput(`${JSON.stringify(json, null, 2)}\n`, output);
} else {
await writeOutput(Buffer.from(result.data), output);
}
}
function parseOptions(argv, booleanNames, valueNames) {
const flags = new Set();
const values = {};
const positionals = [];
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') {
positionals.push(...argv.slice(index + 1));
break;
}
if (!argument.startsWith('-') || argument === '-') {
positionals.push(argument);
continue;
}
const name = argument.replace(/^--?/, '');
if (booleanNames.has(name)) flags.add(name);
else if (valueNames.has(name)) {
const value = argv[++index];
if (value === undefined) throw new Error(`${argument} requires a value`);
values[name] = value;
} else throw new Error(`Unknown option: ${argument}`);
}
return { flags, values, positionals };
}
async function readInput(file) {
if (file && file !== '-') return fs.readFile(file);
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks);
}
async function writeOutput(data, file) {
if (file && file !== '-') await fs.writeFile(file, data);
else process.stdout.write(data);
}
main().catch((error) => {
process.stderr.write(`crypstie: ${error.message}\n`);
process.exitCode = 1;
});

209
src/index.js Normal file
View File

@ -0,0 +1,209 @@
'use strict';
const unicrypto = require('unicrypto');
const { Boss, SymmetricKey } = unicrypto;
const DEFAULT_ROOT_URL = 'https://crypstie.com';
const DEFAULT_LIFETIME_MS = 90 * 24 * 60 * 60 * 1000;
class CrypstieError extends Error {
constructor(message, options = {}) {
super(message, options);
this.name = 'CrypstieError';
this.status = options.status;
}
}
let cryptoReady;
function initializeCrypto() {
if (cryptoReady) return cryptoReady;
// unicrypto's Emscripten loader predates Node's global fetch. Node 18+
// otherwise tries to fetch an absolute filesystem path as an HTTP URL.
const instantiateStreaming = WebAssembly.instantiateStreaming;
try {
WebAssembly.instantiateStreaming = undefined;
cryptoReady = unicrypto.unicryptoReady;
} finally {
WebAssembly.instantiateStreaming = instantiateStreaming;
}
return cryptoReady;
}
function decodeCrypstieKey(encoded) {
if (typeof encoded !== 'string' || encoded.length === 0) {
throw new TypeError('Crypstie link has no decryption key');
}
if (!/^[A-Za-z0-9._-]+={0,2}$/.test(encoded)) {
throw new TypeError('Crypstie link contains an invalid decryption key');
}
const base64 = encoded.replaceAll('.', '+').replaceAll('_', '/');
const key = Buffer.from(base64, 'base64');
if (key.length !== 32) {
throw new TypeError(`Invalid Crypstie key length: expected 32 bytes, got ${key.length}`);
}
return new Uint8Array(key);
}
function encodeCrypstieKey(key) {
const bytes = toUint8Array(key);
if (bytes.length !== 32) {
throw new TypeError(`Invalid Crypstie key length: expected 32 bytes, got ${bytes.length}`);
}
return Buffer.from(bytes)
.toString('base64')
.replaceAll('+', '.')
.replaceAll('/', '_')
.replace(/=+$/, '');
}
function parseCrypstieUrl(value) {
let url;
try {
url = value instanceof URL ? new URL(value.href) : new URL(value);
} catch (error) {
throw new TypeError(`Invalid Crypstie URL: ${error.message}`);
}
const match = /^\/_([^/]+)$/.exec(url.pathname);
if (!match) throw new TypeError('Invalid Crypstie URL path: expected /_<id>');
return { url, id: match[1], key: decodeCrypstieKey(url.hash.slice(1)) };
}
async function createCrypstie(data, options = {}) {
await initializeCrypto();
const source = typeof data === 'string' ? new TextEncoder().encode(data) : toUint8Array(data);
const key = options.key
? new SymmetricKey({ keyBytes: toUint8Array(options.key) })
: new SymmetricKey();
const deleteAt = normalizeDate(options.deleteAt ?? new Date(Date.now() + DEFAULT_LIFETIME_MS));
const rootUrl = normalizeRootUrl(options.rootUrl);
const packed = Boss.dump({
guid: '',
encryptedData: key.etaEncryptSync(source),
deleteAt,
burnOnShow: options.burnOnShow === true,
showWide: options.showWide === true,
syntaxHighlighting: options.syntaxHighlighting ?? null,
editable: options.editable === true,
ownerHandle: options.ownerHandle ?? null,
});
const form = new FormData();
form.append('data', new Blob([packed]), 'crypstie.boss');
const response = await getFetch(options.fetch)(new URL('/api/crypstie', rootUrl), {
method: 'POST',
body: form,
signal: options.signal,
});
await requireOk(response, 'create');
const id = (await response.text()).trim();
if (!id || /[\s/#?]/.test(id)) {
throw new CrypstieError('Crypstie server returned an invalid id');
}
const url = new URL(`/_${id}`, rootUrl);
url.hash = encodeCrypstieKey(key.pack());
return {
id,
url: url.href,
deleteAt,
burnOnShow: options.burnOnShow === true,
};
}
async function decryptCrypstiePayload(packed, key) {
await initializeCrypto();
const record = Boss.load(toUint8Array(packed));
if (!record || !(record.encryptedData instanceof Uint8Array)) {
throw new CrypstieError('Invalid Crypstie response: encryptedData is missing');
}
let data;
try {
data = new SymmetricKey({ keyBytes: toUint8Array(key) })
.etaDecryptSync(record.encryptedData);
} catch (error) {
throw new CrypstieError('Failed to authenticate or decrypt Crypstie', { cause: error });
}
return {
id: record.guid,
data,
text: new TextDecoder().decode(data),
deleteAt: record.deleteAt instanceof Date ? record.deleteAt : null,
burnOnShow: record.burnOnShow === true,
showWide: record.showWide === true,
syntaxHighlighting: record.syntaxHighlighting ?? null,
editable: record.editable === true,
ownerHandle: record.ownerHandle ?? null,
};
}
async function openCrypstie(value, options = {}) {
const { url, id, key } = parseCrypstieUrl(value);
const requestUrl = new URL(`/api/crypstie/${encodeURIComponent(id)}`, url.origin);
if (Array.isArray(options.profileIds) && options.profileIds.length > 0) {
requestUrl.searchParams.set('pids', options.profileIds.join(','));
}
const response = await getFetch(options.fetch)(requestUrl, { signal: options.signal });
await requireOk(response, 'open');
const result = await decryptCrypstiePayload(await response.arrayBuffer(), key);
return { ...result, id: result.id || id, url: url.href };
}
function normalizeRootUrl(value = DEFAULT_ROOT_URL) {
const url = new URL(value);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new TypeError('Crypstie root URL must use HTTP or HTTPS');
}
return url;
}
function normalizeDate(value) {
const date = value instanceof Date ? new Date(value) : new Date(value);
if (!Number.isFinite(date.getTime())) throw new TypeError('Invalid deleteAt date');
if (date.getTime() <= Date.now()) throw new TypeError('deleteAt must be in the future');
return date;
}
function getFetch(fetchImpl = globalThis.fetch) {
if (typeof fetchImpl !== 'function') throw new TypeError('No fetch implementation is available');
return fetchImpl;
}
async function requireOk(response, operation) {
if (response.ok) return;
let detail = '';
try { detail = (await response.text()).trim(); } catch {}
throw new CrypstieError(
`Failed to ${operation} Crypstie: HTTP ${response.status}${detail ? `: ${detail}` : ''}`,
{ status: response.status },
);
}
function toUint8Array(value) {
if (value instanceof Uint8Array) return value;
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
throw new TypeError('Expected a string or binary data');
}
module.exports = {
CrypstieError,
DEFAULT_ROOT_URL,
createCrypstie,
decodeCrypstieKey,
decryptCrypstiePayload,
encodeCrypstieKey,
initializeCrypto,
openCrypstie,
parseCrypstieUrl,
};

26
test/cli.test.js Normal file
View File

@ -0,0 +1,26 @@
'use strict';
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('node:child_process');
const path = require('node:path');
const test = require('node:test');
const pkg = require('../package.json');
const cli = path.join(__dirname, '..', 'src', 'cli.js');
test('CLI prints concise help', () => {
const output = execFileSync(process.execPath, [cli, '--help'], { encoding: 'utf8' });
assert.match(output, /crypstie create \[file\|-\]/);
assert.match(output, /crypstie open <url>/);
});
test('CLI prints package version', () => {
assert.equal(execFileSync(process.execPath, [cli, '--version'], { encoding: 'utf8' }), `${pkg.version}\n`);
});
test('CLI rejects unknown commands without a stack trace', () => {
const result = spawnSync(process.execPath, [cli, 'oops'], { encoding: 'utf8' });
assert.equal(result.status, 1);
assert.match(result.stderr, /^crypstie: Unknown command/);
assert.equal(result.stderr.includes(' at '), false);
});

127
test/client.test.js Normal file
View File

@ -0,0 +1,127 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const { Boss, SymmetricKey } = require('unicrypto');
const {
createCrypstie,
decryptCrypstiePayload,
encodeCrypstieKey,
initializeCrypto,
openCrypstie,
parseCrypstieUrl,
} = require('../src');
const KEY_BYTES = Uint8Array.from({ length: 32 }, (_, index) => index + 1);
const KEY_TEXT = Buffer.from(KEY_BYTES)
.toString('base64')
.replaceAll('+', '.')
.replaceAll('/', '_')
.replace(/=+$/, '');
test('parses the legacy Crypstie URL format', () => {
const parsed = parseCrypstieUrl(`https://crypstie.com/_sample#${KEY_TEXT}`);
assert.equal(parsed.id, 'sample');
assert.deepEqual(parsed.key, KEY_BYTES);
});
test('encodes and decodes legacy Crypstie keys', () => {
assert.deepEqual(parseCrypstieUrl(`https://crypstie.com/_x#${encodeCrypstieKey(KEY_BYTES)}`).key, KEY_BYTES);
});
test('rejects a link without its fragment key', () => {
assert.throws(
() => parseCrypstieUrl('https://crypstie.com/_sample'),
/no decryption key/,
);
});
test('decrypts a BOSS payload produced in the Crypstie wire format', async () => {
await initializeCrypto();
const key = new SymmetricKey({ keyBytes: KEY_BYTES });
const deleteAt = new Date('2030-01-02T03:04:05Z');
const packed = Boss.dump({
guid: 'sample',
encryptedData: key.etaEncryptSync(new TextEncoder().encode('совместимость работает')),
deleteAt,
burnOnShow: false,
showWide: true,
syntaxHighlighting: 'text',
editable: false,
ownerHandle: null,
});
const result = await decryptCrypstiePayload(packed, KEY_BYTES);
assert.equal(result.text, 'совместимость работает');
assert.equal(result.id, 'sample');
assert.equal(result.deleteAt.toISOString(), deleteAt.toISOString());
assert.equal(result.burnOnShow, false);
assert.equal(result.showWide, true);
});
test('downloads the encrypted payload without sending the fragment key', async () => {
await initializeCrypto();
const key = new SymmetricKey({ keyBytes: KEY_BYTES });
const packed = Boss.dump({
guid: 'sample',
encryptedData: key.etaEncryptSync(new TextEncoder().encode('secret')),
deleteAt: new Date('2030-01-02T03:04:05Z'),
burnOnShow: false,
showWide: false,
editable: false,
});
let requested;
const fakeFetch = async (url) => {
requested = url.href;
return new Response(packed, { status: 200 });
};
const result = await openCrypstie(
`https://crypstie.com/_sample#${KEY_TEXT}`,
{ fetch: fakeFetch },
);
assert.equal(requested, 'https://crypstie.com/api/crypstie/sample');
assert.equal(requested.includes(KEY_TEXT), false);
assert.equal(result.text, 'secret');
});
test('creates a server-compatible encrypted BOSS payload', async () => {
let request;
const fakeFetch = async (url, options) => {
request = { url: url.href, options };
return new Response('new_id', { status: 200 });
};
const deleteAt = new Date(Date.now() + 60_000);
const result = await createCrypstie('created by node', {
key: KEY_BYTES,
deleteAt,
rootUrl: 'https://example.test/base',
burnOnShow: true,
fetch: fakeFetch,
});
assert.equal(request.url, 'https://example.test/api/crypstie');
assert.equal(request.options.method, 'POST');
assert.equal(result.url, `https://example.test/_new_id#${KEY_TEXT}`);
assert.equal(result.burnOnShow, true);
const uploaded = Boss.load(new Uint8Array(await request.options.body.get('data').arrayBuffer()));
assert.equal(uploaded.burnOnShow, true);
assert.equal(
uploaded.deleteAt.toISOString(),
new Date(Math.floor(deleteAt.getTime() / 1000) * 1000).toISOString(),
);
assert.equal(
new TextDecoder().decode(new SymmetricKey({ keyBytes: KEY_BYTES }).etaDecryptSync(uploaded.encryptedData)),
'created by node',
);
});
test('reports authentication failure for a wrong key', async () => {
await initializeCrypto();
const key = new SymmetricKey({ keyBytes: KEY_BYTES });
const packed = Boss.dump({ encryptedData: key.etaEncryptSync(new TextEncoder().encode('secret')) });
const wrongKey = Uint8Array.from(KEY_BYTES, (byte) => byte ^ 0xff);
await assert.rejects(() => decryptCrypstiePayload(packed, wrongKey), /authenticate or decrypt/);
});

27
test/live.test.js Normal file
View File

@ -0,0 +1,27 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const { createCrypstie, openCrypstie } = require('../src');
test(
'opens a reusable Crypstie from the deployed service',
{ skip: !process.env.CRYPSTIE_TEST_URL },
async () => {
const result = await openCrypstie(process.env.CRYPSTIE_TEST_URL);
assert.match(result.text, /^Crypstie Node compatibility fixture /);
assert.equal(result.burnOnShow, false);
},
);
test(
'creates and reopens a reusable Crypstie on the deployed service',
{ skip: process.env.CRYPSTIE_LIVE_WRITE !== '1' },
async () => {
const marker = `Crypstie Node compatibility fixture ${new Date().toISOString()}`;
const created = await createCrypstie(marker);
const opened = await openCrypstie(created.url);
assert.equal(opened.text, marker);
assert.equal(opened.burnOnShow, false);
},
);