Раздел 46 · Effect: Тихая Академия

Конфигурация и Redacted

middle~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Сейчас позывной T-1 зашит в код: в checkFrame и в callSign с урока 36. Вторая башня с тем же кодом назвалась бы T-1 и принимала бы чужие кадры. Ключа печати нет вовсе.

Значения, которые меняются от запуска к запуску, а не от версии к версии, живут снаружи программы: в переменных окружения, в файле, в хранилище секретов. Программа их читает. Effect делает из этого чтения квитанцию с ошибкой в типе, а из прочитанного значения сервис.

Config

Config описывает значение, которое надо прочитать: имя и тип.

import { Config } from 'effect';

const callSign = Config.String('TOWER_CALLSIGN');
// Config<string>

В Effect 4 Config это квитанция: Config<string> наследует Effect<string, ConfigError>. Его можно yield* в генераторе и отдать Layer.effect без обёрток. Ошибка ConfigError значит «значения нет или оно не того типа», и она в типе.

Кроме String есть Int, Number, Boolean, Port, URL, Duration, Literals и Array: каждый разбирает строку из окружения в свой тип и падает ConfigError, если не вышло.

Запасные варианты

Позывной у башни читают так: новое имя переменной, если его нет, старое, а если нет и его, T-1.

const callSign = Config.String('TOWER_CALLSIGN').pipe(
  Config.orElse(() => Config.String('CALLSIGN')),
  Config.withDefault('T-1'),
);

orElse пробует второе описание, если первое не прочиталось. withDefault отдаёт значение по умолчанию, если значения нет совсем. Тип после него тот же, Config<string>: у Config нет параметра ошибки, ConfigError возможен всегда. У withDefault над Config.Int это видно сразу: значение TOWER_PORT=восемь есть, но не разбирается, и запасной вариант его не спасёт.

Кто читает

Описание само ничего не читает. Читает поставщик конфигурации, ConfigProvider. По умолчанию он читает переменные окружения. В тесте его подменяют объектом в памяти:

import { Config, ConfigProvider, Effect } from 'effect';

const read = (env: Record<string, string>) =>
  Effect.runSync(Effect.provide(callSign, ConfigProvider.layer(ConfigProvider.fromUnknown(env))));

console.log(read({ TOWER_CALLSIGN: 'T-4', CALLSIGN: 'T-5' }), read({ CALLSIGN: 'T-5' }), read({}));
T-4 T-5 T-1

ConfigProvider.layer(...) это обычный слой, и кладут его обычным provide. Поэтому тесту не нужно трогать настоящее окружение процесса: переменные остаются чужими, а тест читает свои.

Redacted

Ключ печати это секрет. Его читают через Config.Redacted:

const key = Config.Redacted('TOWER_KEY');
// Config<Redacted<string>>

Redacted<string> это коробка, которая не показывает содержимое. Её можно печатать, класть в журнал, сериализовать, и везде будет одна заглушка:

console.log(found, String(found), JSON.stringify({ key: found }));
console.log(`ключ: ${found}`);
<redacted> <redacted> {"key":"<redacted>"}
ключ: <redacted>

Достать значение можно только явно: Redacted.value(key). Такой вызов виден в коде и находится поиском, поэтому место, где секрет покидает коробку, одно и его легко проверить.

Если ключа нет, чтение падает:

ConfigError: SchemaError(Expected string
  at ["TOWER_KEY"]) {
  [cause]: SchemaError: Expected string
    at ["TOWER_KEY"]
}

В ошибке есть имя переменной и что ожидалось. Значения нет: его и не было.

Сервис из конфигурации

Позывной и ключ становятся сервисами. Сервису не обязательно быть объектом с методами, форма может быть строкой:

export class CallSign extends Context.Service<CallSign, string>()('bashnya/CallSign') {
  static readonly layerConfig = Layer.effect(
    CallSign,
    Config.String('TOWER_CALLSIGN').pipe(
      Config.orElse(() => Config.String('CALLSIGN')),
      Config.withDefault('T-1'),
    ),
  );
}

export class TowerKey extends Context.Service<TowerKey, Redacted.Redacted<string>>()(
  'bashnya/TowerKey',
) {
  static readonly layerConfig = Layer.effect(TowerKey, Config.Redacted('TOWER_KEY'));
}

Это третье имя из соглашения урока 48: layerConfig, сборка из конфигурации. Тип у слоя ключа Layer<TowerKey, ConfigError>: ошибка чтения стала ошибкой сборки слоя. Без ключа башня не соберётся, и это видно в типе, а не в три часа ночи.

Почему сервис, а не просто константа, прочитанная при старте? Потому что тест подложит свой позывной одной строкой Effect.provideService(CallSign, 'T-4'), не трогая конфигурацию вообще, а вторая башня в той же программе может получить свой.

Печать под сводкой берёт ключ из сервиса и достаёт значение в одном месте:

export const seal = (text: string): Effect.Effect<string, never, TowerKey> =>
  TowerKey.use((key) =>
    Effect.succeed((Hash.string(`${Redacted.value(key)}|${text}`) >>> 0).toString(16)),
  );

export const signedSummary = Effect.fn('Tower.signedSummary')(function* (
  report: NightReport,
): Effect.fn.Return<string, never, CallSign | TowerKey> {
  const text = `${yield* CallSign} · ${summary(report)}`;
  return `${text} · печать ${yield* seal(text)}`;
});
T-4 · обработано 2, ошибок 1, призраков 0, дублей 0, сбоев 0 · печать 1e5c90a6

Hash.string из модуля Hash даёт число по строке; >>> 0 делает его беззнаковым, toString(16) записывает шестнадцатерично. Это учебная печать: настоящую подпись делают криптографическим кодом аутентификации сообщения, а не хешем для таблиц. Устройство то же: секрет плюс текст, и без секрета печать не подделать.

Перевод с Ук

Первая попытка печати, ключ прямо в Hash.string:

export const seal = (text: string): Effect.Effect<number, never, TowerKey> =>
  TowerKey.use((key) => Effect.succeed(Hash.string(key) ^ Hash.string(text)));
ex51d.ts(6,52): error TS2345: Argument of type 'Redacted<string>' is not assignable to parameter of type 'string'.

Ошибка в одну строку, и она ровно о том, о чём кажется: Hash.string ждёт строку, а key это Redacted<string>, коробка со строкой внутри. Коробка не строка, и компилятор не даст сделать вид, что строка. Лечится явным Redacted.value(key). Ради этой ошибки Redacted и заведён: каждое место, где секрет вынимают, видно.

Где тут дыра

const seal = (text: string): Effect.Effect<string, never, TowerKey> =>
  TowerKey.use((key) => Effect.succeed((Hash.string(`${key}|${text}`) >>> 0).toString(16)));

const sealWith = (key: string) =>
  Effect.runSync(seal('T-1 · обработано 4').pipe(Effect.provideService(TowerKey, Redacted.make(key))));

console.log(sealWith('грозовая-тайна'), sealWith('совсем-другой-ключ'));
1ee1ef75 1ee1ef75

Печать ставится, и на одной башне всё выглядит правильно. Сравни две башни с разными ключами: печать одна и та же. Шаблонная строка превратила коробку в <redacted>, и Ринцвальд хеширует слово <redacted>, а не ключ. Такую печать подделает кто угодно, кто знает формат сводки, а Гилт его знает.

Компилятор тут бессилен: Redacted умеет превращаться в строку, на то он и сделан, чтобы безопасно печататься. Лечится только Redacted.value(key) и тестом, который сравнивает печати двух ключей. В тесте этого этапа такая проверка есть.

Башня, этап 16

У башни появляются позывной и ключ печати, оба из конфигурации и оба сервисами. Позывной читается по цепочке из трёх вариантов, ключ только из TOWER_KEY и только в коробке. Сводка ночи получает позывной и печать.

Практика

Дальше

Пондер читает журнал первой смены второй башни и хмурится.

Значение, у которого есть вариант по умолчанию и которое не нужно требовать, ждёт в следующем уроке.