Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions docs/website/documentation/http/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ description: "Use HTTP environments in massCode to manage reusable variables for

Environments store reusable variables for requests. Use them for values that change between local, staging, and production APIs, such as base URLs, user IDs, and organization IDs.

::: warning
Environment variables are stored as plain text in your Markdown Vault. Do not store real passwords, API tokens, private keys, or other secrets in HTTP environments if your vault is synced, shared, or committed to Git.
::: warning Plain text variables
Regular environment variables are stored as plain text in your Markdown Vault, so they travel with it when the vault is synced, shared, or committed to Git. For passwords, API tokens, private keys, and similar values use [secret variables](#secret-variables) instead.
:::

<img :src="withBase('/http-envs.png')">
Expand All @@ -31,6 +31,48 @@ Variables can be used in:
- body
- auth fields

## Secret Variables

<AppVersion text=">=5.10" />

Secret variables keep sensitive values out of the vault. The value is encrypted with your operating system keychain and stored locally, outside the Markdown Vault.

To create one, open **Manage**, click **Add secret**, then enter a name and a value. The value is saved when you leave the value field.

You can also protect a variable that already exists: select the **Secret** checkbox next to it. massCode encrypts the current value and removes it from the vault.

::: warning Rotate values you protect later
By the time you select the checkbox, the plain text value has almost certainly been written to `.state.yaml` by autosave, so it may already have reached a synced cloud folder or your Git history. Removing it from the vault does not undo that. Treat such values as exposed and rotate them.
:::

Secrets are used like any other variable, with the same <code v-pre>{{name}}</code> syntax. When a request runs, massCode substitutes the real value, but the request history stores only a mask.

Working with existing secrets:

- Click the eye icon to show the stored value.
- Clear the **Secret** checkbox to stop protecting a variable. Its value is moved back into the environment as a regular plain text variable.
- To rename a secret, delete it and add it again.

::: warning Secrets are not synced
Only the secret name is stored in the vault. The value never leaves the device where you entered it, so on other devices the same secret shows **Not set on this device** and resolves to an empty value until you enter it there.

On Linux, encryption depends on a supported system keyring. If one is not available, secret storage is disabled and **Add secret** cannot be used.
:::

### How Secrets Are Stored

Values live in massCode application data, in an `http-secrets.json` file next to the other app settings, and are encrypted by the operating system: Keychain on macOS, DPAPI on Windows, and the system keyring on Linux.

Each value is bound to a pair of a vault, identified by its path on disk, and an environment. This has a few practical consequences:

- Moving the vault in **Settings → Storage** carries secret values along with it.
- Switching to a different existing vault does not pick up values entered for the previous one, because it is another vault.
- Deleting an environment also deletes its local secret values.

Values are decrypted only in the main process, when a request runs or when you click the eye icon to reveal a single value. The interface receives a value only for that explicit reveal action. The request preview, the cURL command you copy from it, and the request history (both the URL and the error text) show a mask. During normal execution, the real value goes only into the outgoing network request.

If a value cannot be decrypted, for example because the file was written by a different operating system user or the keychain has changed, the secret is treated as not set: it shows **Not set on this device** and resolves to an empty value. The request still runs.

## Active Environment

Use the **Environments** panel below folders to choose the active environment.
Expand Down
2 changes: 1 addition & 1 deletion docs/website/documentation/http/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The request editor is split into focused tabs:
- **Params** - query parameters sent with the URL
- **Headers** - request headers
- **Body** - JSON, text, form URL encoded, or multipart form data
- **Auth** - none, bearer token, or basic auth
- **Auth** - none, bearer token, or basic auth. Auth values are stored in the vault as plain text, so keep tokens and passwords in [secret variables](/documentation/http/environments#secret-variables) and reference them here.
- **Description** - markdown notes for the request

Changes are saved automatically while you edit.
Expand Down
4 changes: 3 additions & 1 deletion docs/website/documentation/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ massCode stores your data locally on your computer. Snippets and notes live in a
- **Live updates.** massCode watches the vault in real time, so external file changes appear in the app automatically.

::: warning
Vault files are stored as plain text. Do not store passwords, API tokens, private keys, or other secrets in snippets, notes, HTTP requests, or HTTP environments if your vault is synced, shared, or committed to Git. Use an external secret manager for real credentials.
Vault files are stored as plain text. Do not store passwords, API tokens, private keys, or other secrets in snippets, notes, or HTTP requests if your vault is synced, shared, or committed to Git. Use an external secret manager for real credentials.

In HTTP environments, use [secret variables](/documentation/http/environments#secret-variables) for such values: their names stay in the vault, but the values are kept outside it, encrypted on the device where you entered them.
:::

### How it works
Expand Down
2 changes: 2 additions & 0 deletions docs/website/documentation/sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ This approach works well if you want:

massCode watches the vault directory in real time, so changes made outside the app are picked up automatically.

Values of HTTP [secret variables](/documentation/http/environments#secret-variables) are the one exception: they are stored outside the vault, so they are not synced and have to be entered on each device separately.

## Offloaded (online-only) files

<AppVersion text=">=5.9" />
Expand Down
6 changes: 6 additions & 0 deletions src/main/api/dto/http-environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const httpEnvironmentItem = t.Object({
id: t.Number(),
name: t.String(),
variables: httpEnvironmentVariables,
// Имена secret-переменных. Значения намеренно не отдаются списком: их
// можно получить только точечно, через reveal.
secretKeys: t.Array(t.String()),
// Секреты, объявленные в vault, но не заданные на этом устройстве:
// значения не синхронизируются вместе с vault.
missingSecretKeys: t.Array(t.String()),
createdAt: t.Number(),
updatedAt: t.Number(),
})
Expand Down
40 changes: 36 additions & 4 deletions src/main/api/routes/http-environments.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { HttpEnvironmentsResponse } from '../dto/http-environments'
import { Elysia } from 'elysia'
import {
deleteEnvironmentSecrets,
getUsableSecretKeys,
} from '../../http/secrets'
import { useHttpStorage } from '../../storage'
import { log } from '../../utils'
import { commonAddResponse } from '../dto/common/response'
import { httpEnvironmentsDTO } from '../dto/http-environments'

Expand Down Expand Up @@ -64,7 +69,27 @@ app
'/',
() => {
const storage = useHttpStorage()
const items = storage.environments.getEnvironments()
const items = storage.environments.getEnvironments().map((env) => {
const secretKeys = env.secretKeys ?? []
const storedKeys = new Set(
getUsableSecretKeys(env.secretStorageId ?? String(env.id)),
)
const variables = Object.fromEntries(
Object.entries(env.variables).filter(
([key]) => !secretKeys.includes(key),
),
)

return {
id: env.id,
name: env.name,
variables,
secretKeys,
missingSecretKeys: secretKeys.filter(key => !storedKeys.has(key)),
createdAt: env.createdAt,
updatedAt: env.updatedAt,
}
})
const activeId = storage.environments.getActiveEnvironmentId()

return { activeId, items } as HttpEnvironmentsResponse
Expand Down Expand Up @@ -130,14 +155,21 @@ app
'/:id',
({ params, status }) => {
const storage = useHttpStorage()
const { deleted } = storage.environments.deleteEnvironment(
Number(params.id),
)
const id = Number(params.id)
const { deleted, secretScopeId }
= storage.environments.deleteEnvironment(id)

if (!deleted) {
return status(404, { message: 'Environment not found' })
}

try {
deleteEnvironmentSecrets(secretScopeId!)
}
catch (error) {
log('http:delete-environment-secrets-cleanup', error)
}

return { message: 'Environment deleted' }
},
{
Expand Down
57 changes: 57 additions & 0 deletions src/main/http/secrets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { getVaultPath } from '../storage/providers/markdown/runtime/paths'
import { store } from '../store'

/**
* Значения secret-переменных HTTP-окружений. В vault хранятся только имена
* (`secretKeys` в .state.yaml), сами значения лежат локально и зашифрованы
* через OS keychain, поэтому не уезжают в облачную папку вместе с vault.
*/
export function isSecretsEncryptionAvailable(): boolean {
return store.httpSecrets.isEncryptionAvailable()
}

export function getEnvironmentSecrets(scopeId: string): Record<string, string> {
return store.httpSecrets.getSecrets(getVaultPath(), scopeId)
}

export function getUsableSecretKeys(scopeId: string): string[] {
return store.httpSecrets.getUsableKeys(getVaultPath(), scopeId)
}

export function revealEnvironmentSecret(
scopeId: string,
key: string,
): string | null {
return store.httpSecrets.getSecret(getVaultPath(), scopeId, key)
}

export function setEnvironmentSecret(
scopeId: string,
key: string,
value: string,
): void {
store.httpSecrets.setSecret(getVaultPath(), scopeId, key, value)
}

export function deleteEnvironmentSecret(scopeId: string, key: string): void {
store.httpSecrets.deleteSecret(getVaultPath(), scopeId, key)
}

export function deleteEnvironmentSecrets(scopeId: string): void {
store.httpSecrets.deleteEnvironmentSecrets(getVaultPath(), scopeId)
}

export function moveSecretsToVault(
fromVaultPath: string,
toVaultPath: string,
): void {
store.httpSecrets.moveVault(fromVaultPath, toVaultPath)
}

export function remapEnvironmentSecrets(fromId: number, toId: number): void {
store.httpSecrets.renameEnvironment(
getVaultPath(),
String(fromId),
String(toId),
)
}
7 changes: 7 additions & 0 deletions src/main/i18n/locales/en_US/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"delete": "Are you sure you want to delete \"{{name}}\"?",
"deleteVariable": "Are you sure you want to delete variable \"{{name}}\"?",
"deleteUnnamedVariable": "Are you sure you want to delete this variable?",
"unprotectHttpSecret": "Stop protecting \"{{name}}\"?",
"deletePermanently": "Are you sure you want to permanently delete \"{{name}}\"?",
"deleteConfirmMultipleSnippets": "Are you sure you want to permanently delete {{count}} selected snippets?",
"emptyTrash": "Are you sure you want to permanently delete all snippets in Trash?",
Expand Down Expand Up @@ -33,6 +34,7 @@
},
"warning": {
"noUndo": "You cannot undo this action.",
"httpSecretToPlainText": "Its value will be moved into the environment as plain text, so it will be stored in the vault and synced with it.",
"allSnippetsMoveToTrash": "All snippets in this folder will be moved to trash.",
"taskPropertiesRemoved": "Task status, priority, and due date will be removed.",
"deleteTag": "This will also cause all snippets to have that tag removed.",
Expand All @@ -49,6 +51,11 @@
},
"error": {
"migration": "Auto-migration from SQLite failed: {{error}}",
"secretNameRequired": "Enter a name for the secret before saving its value.",
"secretEnvironmentMissing": "This environment no longer exists.",
"secretEncryptionUnavailable": "Secrets cannot be stored: this system has no OS encryption available.",
"secretSaveFailed": "Failed to save the secret.",
"secretRevealFailed": "Failed to read the secret. It may have been saved by another OS user.",
"entryNameEmpty": "Name cannot be empty.",
"entryNameLeadingDot": "Name cannot start with a dot.",
"entryNameInvalidChars": "Name cannot contain these characters: {{- chars}}",
Expand Down
11 changes: 10 additions & 1 deletion src/main/i18n/locales/en_US/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@
"noVariables": "No variables yet",
"varKey": "Variable",
"varValue": "Value",
"varSecret": "Secret",
"secretHint": "Keep the value encrypted on this device only, outside the vault",
"secretUnavailable": "This system has no OS encryption available for secrets",
"secretNotSet": "Not set on this device",
"secretKeyLocked": "To rename a secret, delete it and add it again",
"revealSecret": "Show value",
"hideSecret": "Hide value",
"addSecret": "Add secret",
"addVariable": "Add variable",
"noEnvironmentSelected": "Select an environment to edit its variables"
},
Expand Down Expand Up @@ -306,7 +314,8 @@
"username": "Username",
"usernamePlaceholder": "Enter username",
"password": "Password",
"passwordPlaceholder": "Enter password"
"passwordPlaceholder": "Enter password",
"plainTextHint": "Auth values are stored in the vault as plain text. For sensitive data create a secret environment variable and reference it as {{API_KEY}}."
},
"description": {
"placeholder": "Add a description for this request"
Expand Down
7 changes: 7 additions & 0 deletions src/main/i18n/locales/ru_RU/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"delete": "Вы уверены, что хотите удалить \"{{name}}\"?",
"deleteVariable": "Вы уверены, что хотите удалить переменную \"{{name}}\"?",
"deleteUnnamedVariable": "Вы уверены, что хотите удалить эту переменную?",
"unprotectHttpSecret": "Снять защиту с \"{{name}}\"?",
"deletePermanently": "Вы уверены, что хотите навсегда удалить \"{{name}}\"?",
"deleteConfirmMultipleSnippets": "Вы уверены, что хотите навсегда удалить {{count}} выбранных сниппетов?",
"emptyTrash": "Вы уверены, что хотите навсегда удалить все сниппеты из Корзины?",
Expand All @@ -28,6 +29,7 @@
},
"warning": {
"noUndo": "Это действие нельзя отменить.",
"httpSecretToPlainText": "Значение переменной станет обычным текстом, то есть будет сохранено в vault и синхронизировано вместе с ним.",
"allSnippetsMoveToTrash": "Все сниппеты в этой папке будут перемещены в корзину.",
"taskPropertiesRemoved": "Статус задачи, приоритет и срок будут удалены.",
"deleteTag": "Это также приведет к удалению этого тега со всех сниппетов.",
Expand All @@ -44,6 +46,11 @@
},
"error": {
"migration": "Автомиграция из SQLite завершилась ошибкой: {{error}}",
"secretNameRequired": "Укажите имя секрета, прежде чем сохранять значение.",
"secretEnvironmentMissing": "Это окружение больше не существует.",
"secretEncryptionUnavailable": "Секреты нельзя сохранить: в этой системе недоступно шифрование ОС.",
"secretSaveFailed": "Не удалось сохранить секрет.",
"secretRevealFailed": "Не удалось прочитать секрет. Возможно, он сохранён другим пользователем ОС.",
"entryNameEmpty": "Имя не может быть пустым.",
"entryNameLeadingDot": "Имя не может начинаться с точки.",
"entryNameInvalidChars": "Имя не может содержать эти символы: {{- chars}}",
Expand Down
11 changes: 10 additions & 1 deletion src/main/i18n/locales/ru_RU/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,14 @@
"noVariables": "Переменных пока нет",
"varKey": "Переменная",
"varValue": "Значение",
"varSecret": "Секрет",
"secretHint": "Хранить значение зашифрованным только на этом устройстве, вне vault",
"secretUnavailable": "В этой системе недоступно шифрование ОС для секретов",
"secretNotSet": "Не задано на этом устройстве",
"secretKeyLocked": "Чтобы переименовать секрет, удалите его и добавьте заново",
"revealSecret": "Показать значение",
"hideSecret": "Скрыть значение",
"addSecret": "Добавить секрет",
"addVariable": "Добавить переменную",
"noEnvironmentSelected": "Выберите окружение, чтобы отредактировать его переменные"
},
Expand Down Expand Up @@ -309,7 +317,8 @@
"username": "Логин",
"usernamePlaceholder": "Введите логин",
"password": "Пароль",
"passwordPlaceholder": "Введите пароль"
"passwordPlaceholder": "Введите пароль",
"plainTextHint": "Значения авторизации хранятся в vault открытым текстом. Для чувствительных данных создайте secret-переменную окружения и ссылайтесь на неё как {{API_KEY}}."
},
"description": {
"placeholder": "Добавьте описание для этого запроса"
Expand Down
4 changes: 4 additions & 0 deletions src/main/ipc/handlers/__tests__/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ vi.mock('undici', () => ({
request: requestMock,
}))

vi.mock('../../../http/secrets', () => ({
getEnvironmentSecrets: () => ({}),
}))

vi.mock('../../../storage', () => ({
useHttpStorage: () => ({
environments: {
Expand Down
Loading