@neurocode-ai/http-recorder 1.18.14 → 1.18.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +61 -60
- package/src/cassette.ts +179 -0
- package/src/effect.ts +25 -0
- package/src/index.ts +18 -0
- package/src/internal-effect.ts +189 -0
- package/src/internal.ts +15 -0
- package/src/matching.ts +106 -0
- package/src/recorder.ts +62 -0
- package/src/redaction.ts +117 -0
- package/src/redactor.ts +135 -0
- package/src/schema.ts +87 -0
- package/src/socket.ts +326 -0
- package/src/types.ts +108 -0
- package/src/websocket.ts +173 -0
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Effect, Scope, SynchronizedRef } from "effect"
|
|
2
|
+
import type * as CassetteService from "./cassette.js"
|
|
3
|
+
import type { CassetteNotFoundError } from "./cassette.js"
|
|
4
|
+
import type { Interaction } from "./schema.js"
|
|
5
|
+
|
|
6
|
+
const isCI = () => {
|
|
7
|
+
const value = process.env.CI
|
|
8
|
+
return value !== undefined && value !== "" && value !== "false" && value !== "0"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const resolveAutoMode = (
|
|
12
|
+
cassette: CassetteService.Interface,
|
|
13
|
+
name: string,
|
|
14
|
+
): Effect.Effect<"record" | "replay" | "passthrough"> =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
if (isCI()) return "replay"
|
|
17
|
+
return (yield* cassette.exists(name)) ? "replay" : "record"
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export interface ReplayState<T> {
|
|
21
|
+
readonly claim: <E>(
|
|
22
|
+
validate: (interaction: T | undefined, index: number, interactions: ReadonlyArray<T>) => Effect.Effect<void, E>,
|
|
23
|
+
) => Effect.Effect<{ readonly interaction: T; readonly index: number }, CassetteNotFoundError | E>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const makeReplayState = <T>(
|
|
27
|
+
cassette: CassetteService.Interface,
|
|
28
|
+
name: string,
|
|
29
|
+
project: (interactions: ReadonlyArray<Interaction>) => ReadonlyArray<T>,
|
|
30
|
+
): Effect.Effect<ReplayState<T>, never, Scope.Scope> =>
|
|
31
|
+
Effect.gen(function* () {
|
|
32
|
+
const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project)))
|
|
33
|
+
const position = yield* SynchronizedRef.make(0)
|
|
34
|
+
|
|
35
|
+
yield* Effect.addFinalizer(() =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const used = yield* SynchronizedRef.get(position)
|
|
38
|
+
if (used === 0) return yield* Effect.void
|
|
39
|
+
const interactions = yield* load.pipe(Effect.orDie)
|
|
40
|
+
if (used < interactions.length)
|
|
41
|
+
return yield* Effect.die(
|
|
42
|
+
new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`),
|
|
43
|
+
)
|
|
44
|
+
return yield* Effect.void
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
claim: (validate) =>
|
|
50
|
+
Effect.flatMap(load, (interactions) =>
|
|
51
|
+
SynchronizedRef.modifyEffect(position, (index) =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const interaction = interactions[index]
|
|
54
|
+
yield* validate(interaction, index, interactions)
|
|
55
|
+
if (interaction === undefined)
|
|
56
|
+
return yield* Effect.die("Replay validation accepted a missing interaction")
|
|
57
|
+
return [{ interaction, index }, index + 1] as const
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
})
|
package/src/redaction.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
export const REDACTED = "[REDACTED]"
|
|
4
|
+
|
|
5
|
+
const DEFAULT_REDACT_HEADERS = [
|
|
6
|
+
"authorization",
|
|
7
|
+
"cookie",
|
|
8
|
+
"proxy-authorization",
|
|
9
|
+
"set-cookie",
|
|
10
|
+
"x-api-key",
|
|
11
|
+
"x-amz-security-token",
|
|
12
|
+
"x-goog-api-key",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const DEFAULT_REDACT_QUERY = [
|
|
16
|
+
"access_token",
|
|
17
|
+
"api-key",
|
|
18
|
+
"api_key",
|
|
19
|
+
"apikey",
|
|
20
|
+
"code",
|
|
21
|
+
"key",
|
|
22
|
+
"signature",
|
|
23
|
+
"sig",
|
|
24
|
+
"token",
|
|
25
|
+
"x-amz-credential",
|
|
26
|
+
"x-amz-security-token",
|
|
27
|
+
"x-amz-signature",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
|
|
31
|
+
{ label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
|
|
32
|
+
{ label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
|
|
33
|
+
{ label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
|
34
|
+
{ label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
|
|
35
|
+
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
36
|
+
{ label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
|
|
37
|
+
{ label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
|
|
41
|
+
const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
|
|
42
|
+
|
|
43
|
+
const envSecrets = () =>
|
|
44
|
+
Object.entries(process.env).flatMap(([name, value]) => {
|
|
45
|
+
if (!value) return []
|
|
46
|
+
if (!ENV_SECRET_NAMES.test(name)) return []
|
|
47
|
+
if (value.length < 12) return []
|
|
48
|
+
if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
|
|
49
|
+
return [{ name, value }]
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
|
|
53
|
+
|
|
54
|
+
const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
|
|
55
|
+
if (typeof value === "string") return [{ path: base, value }]
|
|
56
|
+
if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
|
|
57
|
+
if (value && typeof value === "object") {
|
|
58
|
+
return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
|
|
59
|
+
}
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
|
|
64
|
+
new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
|
|
65
|
+
|
|
66
|
+
export type UrlRedactor = (url: string) => string
|
|
67
|
+
|
|
68
|
+
export const redactUrl = (
|
|
69
|
+
raw: string,
|
|
70
|
+
query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
|
|
71
|
+
urlRedactor?: UrlRedactor,
|
|
72
|
+
) => {
|
|
73
|
+
if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw
|
|
74
|
+
const url = new URL(raw)
|
|
75
|
+
if (url.username) url.username = REDACTED
|
|
76
|
+
if (url.password) url.password = REDACTED
|
|
77
|
+
const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
|
|
78
|
+
for (const key of url.searchParams.keys()) {
|
|
79
|
+
if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
|
|
80
|
+
}
|
|
81
|
+
return urlRedactor?.(url.toString()) ?? url.toString()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const redactHeaders = (
|
|
85
|
+
headers: Record<string, string>,
|
|
86
|
+
allow: ReadonlyArray<string>,
|
|
87
|
+
redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
|
|
88
|
+
) => {
|
|
89
|
+
const allowed = new Set(allow.map((name) => name.toLowerCase()))
|
|
90
|
+
const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
|
|
91
|
+
return Object.fromEntries(
|
|
92
|
+
Object.entries(headers)
|
|
93
|
+
.map(([name, value]) => [name.toLowerCase(), value] as const)
|
|
94
|
+
.filter(([name]) => allowed.has(name))
|
|
95
|
+
.map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
|
|
96
|
+
.toSorted(([a], [b]) => a.localeCompare(b)),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const SecretFindingSchema = Schema.Struct({
|
|
101
|
+
path: Schema.String,
|
|
102
|
+
reason: Schema.String,
|
|
103
|
+
})
|
|
104
|
+
export type SecretFinding = Schema.Schema.Type<typeof SecretFindingSchema>
|
|
105
|
+
|
|
106
|
+
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> => {
|
|
107
|
+
const environment = envSecrets()
|
|
108
|
+
return stringEntries(value).flatMap((entry) => [
|
|
109
|
+
...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
|
|
110
|
+
path: entry.path,
|
|
111
|
+
reason: item.label,
|
|
112
|
+
})),
|
|
113
|
+
...environment
|
|
114
|
+
.filter((item) => entry.value.includes(item.value))
|
|
115
|
+
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
|
|
116
|
+
])
|
|
117
|
+
}
|
package/src/redactor.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Option } from "effect"
|
|
2
|
+
import { decodeJson } from "./matching.js"
|
|
3
|
+
import { REDACTED, redactHeaders, redactUrl } from "./redaction.js"
|
|
4
|
+
import type { RedactOptions, RequestSnapshot, ResponseSnapshot } from "./types.js"
|
|
5
|
+
|
|
6
|
+
export type { RedactOptions } from "./types.js"
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
|
|
9
|
+
export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
|
|
10
|
+
|
|
11
|
+
const identity = <T>(value: T) => value
|
|
12
|
+
|
|
13
|
+
export interface Redactor {
|
|
14
|
+
readonly request: (snapshot: RequestSnapshot) => RequestSnapshot
|
|
15
|
+
readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const compose = (...redactors: ReadonlyArray<Partial<Redactor>>): Redactor => {
|
|
19
|
+
const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined)
|
|
20
|
+
const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined)
|
|
21
|
+
return {
|
|
22
|
+
request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot),
|
|
23
|
+
response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface HeaderOptions {
|
|
28
|
+
readonly allow?: ReadonlyArray<string>
|
|
29
|
+
readonly redact?: ReadonlyArray<string>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const requestHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
|
33
|
+
request: (snapshot) => ({
|
|
34
|
+
...snapshot,
|
|
35
|
+
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact),
|
|
36
|
+
}),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const responseHeaders = (options: HeaderOptions = {}): Partial<Redactor> => ({
|
|
40
|
+
response: (snapshot) => ({
|
|
41
|
+
...snapshot,
|
|
42
|
+
headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact),
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export interface UrlOptions {
|
|
47
|
+
readonly query?: ReadonlyArray<string>
|
|
48
|
+
readonly transform?: (url: string) => string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const url = (options: UrlOptions = {}): Partial<Redactor> => ({
|
|
52
|
+
request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const body = (transform: (parsed: unknown) => unknown): Partial<Redactor> => ({
|
|
56
|
+
request: (snapshot) => ({
|
|
57
|
+
...snapshot,
|
|
58
|
+
body: Option.match(decodeJson(snapshot.body), {
|
|
59
|
+
onNone: () => snapshot.body,
|
|
60
|
+
onSome: (parsed) => JSON.stringify(transform(parsed)),
|
|
61
|
+
}),
|
|
62
|
+
}),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export interface DefaultRedactorOverrides {
|
|
66
|
+
readonly requestHeaders?: HeaderOptions
|
|
67
|
+
readonly responseHeaders?: HeaderOptions
|
|
68
|
+
readonly url?: UrlOptions
|
|
69
|
+
readonly body?: (parsed: unknown) => unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_REDACT_JSON_FIELDS = [
|
|
73
|
+
"access_token",
|
|
74
|
+
"api_key",
|
|
75
|
+
"apikey",
|
|
76
|
+
"client_secret",
|
|
77
|
+
"password",
|
|
78
|
+
"refresh_token",
|
|
79
|
+
"secret",
|
|
80
|
+
"token",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
const normalizeField = (field: string) => field.replace(/[^a-z0-9]/gi, "").toLowerCase()
|
|
84
|
+
|
|
85
|
+
const redactJsonFields = (value: unknown, fields: ReadonlySet<string>): unknown => {
|
|
86
|
+
if (Array.isArray(value)) return value.map((item) => redactJsonFields(item, fields))
|
|
87
|
+
if (!value || typeof value !== "object") return value
|
|
88
|
+
return Object.fromEntries(
|
|
89
|
+
Object.entries(value).map(([key, child]) => [
|
|
90
|
+
key,
|
|
91
|
+
fields.has(normalizeField(key)) ? REDACTED : redactJsonFields(child, fields),
|
|
92
|
+
]),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const redactBody = (value: string, fields: ReadonlySet<string>, transform: ((body: string) => string) | undefined) => {
|
|
97
|
+
const redacted = Option.match(decodeJson(value), {
|
|
98
|
+
onNone: () => value,
|
|
99
|
+
onSome: (parsed) => JSON.stringify(redactJsonFields(parsed, fields)),
|
|
100
|
+
})
|
|
101
|
+
return transform?.(redacted) ?? redacted
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const make = (options: RedactOptions = {}): Redactor => {
|
|
105
|
+
const fields = new Set([...DEFAULT_REDACT_JSON_FIELDS, ...(options.jsonFields ?? [])].map(normalizeField))
|
|
106
|
+
return compose(
|
|
107
|
+
requestHeaders({
|
|
108
|
+
allow: [...DEFAULT_REQUEST_HEADERS, ...(options.allowRequestHeaders ?? []), ...(options.headers ?? [])],
|
|
109
|
+
redact: options.headers,
|
|
110
|
+
}),
|
|
111
|
+
responseHeaders({
|
|
112
|
+
allow: [...DEFAULT_RESPONSE_HEADERS, ...(options.allowResponseHeaders ?? []), ...(options.headers ?? [])],
|
|
113
|
+
redact: options.headers,
|
|
114
|
+
}),
|
|
115
|
+
url({ query: options.queryParameters, transform: options.url }),
|
|
116
|
+
{
|
|
117
|
+
request: (snapshot) => ({
|
|
118
|
+
...snapshot,
|
|
119
|
+
body: redactBody(snapshot.body, fields, options.body),
|
|
120
|
+
}),
|
|
121
|
+
response: (snapshot) => ({
|
|
122
|
+
...snapshot,
|
|
123
|
+
body: redactBody(snapshot.body, fields, options.body),
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor =>
|
|
130
|
+
compose(
|
|
131
|
+
requestHeaders(overrides.requestHeaders),
|
|
132
|
+
responseHeaders(overrides.responseHeaders),
|
|
133
|
+
url(overrides.url),
|
|
134
|
+
...(overrides.body ? [body(overrides.body)] : []),
|
|
135
|
+
)
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import type {
|
|
3
|
+
CassetteMetadata,
|
|
4
|
+
HttpInteraction,
|
|
5
|
+
RequestSnapshot,
|
|
6
|
+
ResponseSnapshot,
|
|
7
|
+
WebSocketEvent,
|
|
8
|
+
WebSocketInteraction,
|
|
9
|
+
} from "./types.js"
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
CassetteMetadata,
|
|
13
|
+
HttpInteraction,
|
|
14
|
+
RequestSnapshot,
|
|
15
|
+
ResponseSnapshot,
|
|
16
|
+
WebSocketEvent,
|
|
17
|
+
WebSocketInteraction,
|
|
18
|
+
} from "./types.js"
|
|
19
|
+
|
|
20
|
+
export const RequestSnapshotSchema = Schema.Struct({
|
|
21
|
+
method: Schema.String,
|
|
22
|
+
url: Schema.String,
|
|
23
|
+
headers: Schema.Record(Schema.String, Schema.String),
|
|
24
|
+
body: Schema.String,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const ResponseSnapshotSchema = Schema.Struct({
|
|
28
|
+
status: Schema.Number,
|
|
29
|
+
headers: Schema.Record(Schema.String, Schema.String),
|
|
30
|
+
body: Schema.String,
|
|
31
|
+
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
|
|
35
|
+
|
|
36
|
+
export const HttpInteractionSchema = Schema.Struct({
|
|
37
|
+
transport: Schema.tag("http"),
|
|
38
|
+
request: RequestSnapshotSchema,
|
|
39
|
+
response: ResponseSnapshotSchema,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export const WebSocketEventSchema = Schema.Union([
|
|
43
|
+
Schema.Struct({
|
|
44
|
+
direction: Schema.Literals(["client", "server"]),
|
|
45
|
+
kind: Schema.tag("text"),
|
|
46
|
+
body: Schema.String,
|
|
47
|
+
}),
|
|
48
|
+
Schema.Struct({
|
|
49
|
+
direction: Schema.Literals(["client", "server"]),
|
|
50
|
+
kind: Schema.tag("binary"),
|
|
51
|
+
body: Schema.String,
|
|
52
|
+
bodyEncoding: Schema.Literal("base64"),
|
|
53
|
+
}),
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
export const WebSocketInteractionSchema = Schema.Struct({
|
|
57
|
+
transport: Schema.tag("websocket"),
|
|
58
|
+
open: Schema.Struct({
|
|
59
|
+
url: Schema.String,
|
|
60
|
+
headers: Schema.Record(Schema.String, Schema.String),
|
|
61
|
+
}),
|
|
62
|
+
events: Schema.Array(WebSocketEventSchema),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
|
|
66
|
+
Schema.toTaggedUnion("transport"),
|
|
67
|
+
)
|
|
68
|
+
export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
|
|
69
|
+
|
|
70
|
+
export const isHttpInteraction = InteractionSchema.guards.http
|
|
71
|
+
|
|
72
|
+
export const isWebSocketInteraction = InteractionSchema.guards.websocket
|
|
73
|
+
|
|
74
|
+
export const httpInteractions = (interactions: ReadonlyArray<Interaction>) => interactions.filter(isHttpInteraction)
|
|
75
|
+
|
|
76
|
+
export const webSocketInteractions = (interactions: ReadonlyArray<Interaction>) =>
|
|
77
|
+
interactions.filter(isWebSocketInteraction)
|
|
78
|
+
|
|
79
|
+
export const CassetteSchema = Schema.Struct({
|
|
80
|
+
version: Schema.Literal(1),
|
|
81
|
+
metadata: Schema.optional(CassetteMetadataSchema),
|
|
82
|
+
interactions: Schema.Array(InteractionSchema),
|
|
83
|
+
})
|
|
84
|
+
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
|
|
85
|
+
|
|
86
|
+
export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
|
|
87
|
+
export const encodeCassette = Schema.encodeSync(CassetteSchema)
|