@coffre/client 0.0.0 → 0.1.1
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/LICENSE +21 -0
- package/README.md +7 -3
- package/dist/index.d.ts +1722 -0
- package/dist/index.js +255 -0
- package/package.json +35 -5
- package/src/api.ts +1062 -0
- package/src/index.ts +307 -0
- package/src/sync-fields.ts +85 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coffre API as function calls, typed from the server's route table
|
|
3
|
+
* (`api.ts`, generated from it).
|
|
4
|
+
*
|
|
5
|
+
* const coffre = createClient({ url: 'https://coffre.acme.example' });
|
|
6
|
+
* await coffre.secrets.set('market/prod', { DATABASE_URL: '…', OLD_KEY: null });
|
|
7
|
+
*
|
|
8
|
+
* Every call is one `fetch`. Pass a `transport` to send requests somewhere
|
|
9
|
+
* else: the web app hands requests straight to its router, and the CLI adds
|
|
10
|
+
* its own handling of Cloudflare Access redirects.
|
|
11
|
+
*/
|
|
12
|
+
import type { Api, AuthInfo, DryRunOutcome, DryRunResult, SetResult } from './api.ts';
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
AccessValue,
|
|
16
|
+
Api,
|
|
17
|
+
AuditEntryView,
|
|
18
|
+
AuthInfo,
|
|
19
|
+
DryRunOutcome,
|
|
20
|
+
DryRunResult,
|
|
21
|
+
IdentityRow,
|
|
22
|
+
Json,
|
|
23
|
+
Me,
|
|
24
|
+
Member,
|
|
25
|
+
OffboardingReport,
|
|
26
|
+
ProjectSummary,
|
|
27
|
+
RemovedMember,
|
|
28
|
+
RunOutcome,
|
|
29
|
+
SecretKey,
|
|
30
|
+
SecretVersion,
|
|
31
|
+
ServiceTokenRow,
|
|
32
|
+
SessionRow,
|
|
33
|
+
SetResult,
|
|
34
|
+
SyncField,
|
|
35
|
+
SyncProviderInfo,
|
|
36
|
+
SyncView,
|
|
37
|
+
} from './api.ts';
|
|
38
|
+
export { configFromArguments, configFromForm, firstMissing, initialValues, isAsked } from './sync-fields.ts';
|
|
39
|
+
export type { FormValues } from './sync-fields.ts';
|
|
40
|
+
|
|
41
|
+
export type RouteKey = keyof Api;
|
|
42
|
+
/** What a caller sends: the body, or the query string for a GET. */
|
|
43
|
+
export type RouteInput<K extends RouteKey> = Api[K]['input'];
|
|
44
|
+
/** What comes back, as JSON. */
|
|
45
|
+
export type RouteOutput<K extends RouteKey> = Api[K]['output'];
|
|
46
|
+
|
|
47
|
+
type ParamNames<Pattern> = Pattern extends `${string}:${infer Name}/${infer Rest}`
|
|
48
|
+
? Name | ParamNames<`/${Rest}`>
|
|
49
|
+
: Pattern extends `${string}:${infer Name}`
|
|
50
|
+
? Name
|
|
51
|
+
: never;
|
|
52
|
+
|
|
53
|
+
/** `GET /members/:member` takes `{ member }`. */
|
|
54
|
+
export type Params<Key> = { [Name in ParamNames<Key>]: string };
|
|
55
|
+
|
|
56
|
+
export type Transport = (request: Request) => Promise<Response>;
|
|
57
|
+
|
|
58
|
+
export type ClientOptions = {
|
|
59
|
+
/** The instance's origin, e.g. `https://coffre.acme.example`. */
|
|
60
|
+
url: string;
|
|
61
|
+
/** Sent with every request: a bearer token, a Cloudflare Access header. */
|
|
62
|
+
headers?: () => HeadersInit | Promise<HeadersInit>;
|
|
63
|
+
transport?: Transport;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The API's error shape, `{ error, message }`, with the HTTP status. */
|
|
67
|
+
const COFFRE_ERROR = Symbol.for('@coffre/client:CoffreError');
|
|
68
|
+
|
|
69
|
+
export class CoffreError extends Error {
|
|
70
|
+
/**
|
|
71
|
+
* `@coffre/ui`'s prebuilt pages bundle a copy of this class, and a page is
|
|
72
|
+
* handed a client the server made with this package's. So `instanceof`
|
|
73
|
+
* asks for the mark every copy leaves, rather than for this copy's prototype.
|
|
74
|
+
*/
|
|
75
|
+
static [Symbol.hasInstance](value: unknown): boolean {
|
|
76
|
+
return typeof value === 'object' && value !== null && COFFRE_ERROR in value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
readonly [COFFRE_ERROR] = true;
|
|
80
|
+
readonly status: number;
|
|
81
|
+
readonly code: string;
|
|
82
|
+
/** The vault's own code when it refused: `no_grant`, `removed`, `bulk_limit`, ... */
|
|
83
|
+
readonly reason: string | undefined;
|
|
84
|
+
|
|
85
|
+
constructor(status: number, code: string, message: string, reason?: string) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = 'CoffreError';
|
|
88
|
+
this.status = status;
|
|
89
|
+
this.code = code;
|
|
90
|
+
this.reason = reason;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type Args<K extends RouteKey> =
|
|
95
|
+
RouteInput<K> extends undefined
|
|
96
|
+
? [params: Params<K>, input?: undefined]
|
|
97
|
+
: Partial<RouteInput<K>> extends RouteInput<K>
|
|
98
|
+
? [params: Params<K>, input?: RouteInput<K>]
|
|
99
|
+
: [params: Params<K>, input: RouteInput<K>];
|
|
100
|
+
|
|
101
|
+
/** `market/prod/KEY` as route parameters. */
|
|
102
|
+
function place(path: string): { project: string; environment: string; key: string } {
|
|
103
|
+
const [project = '', environment = '', key = ''] = path.replace(/^\/+|\/+$/g, '').split('/');
|
|
104
|
+
return { project, environment, key };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createClient(options: ClientOptions) {
|
|
108
|
+
const origin = options.url.replace(/\/+$/, '');
|
|
109
|
+
const transport = options.transport ?? ((request: Request) => fetch(request));
|
|
110
|
+
|
|
111
|
+
async function send(method: string, path: string, input: unknown): Promise<unknown> {
|
|
112
|
+
const url = new URL(`${origin}/api${path}`);
|
|
113
|
+
const headers = new Headers(await options.headers?.());
|
|
114
|
+
let body: string | undefined;
|
|
115
|
+
if (method === 'GET') {
|
|
116
|
+
for (const [name, value] of Object.entries(input ?? {})) {
|
|
117
|
+
if (value !== undefined && value !== null) url.searchParams.set(name, String(value));
|
|
118
|
+
}
|
|
119
|
+
} else if (input !== undefined) {
|
|
120
|
+
headers.set('content-type', 'application/json');
|
|
121
|
+
body = JSON.stringify(input);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const response = await transport(new Request(url, { method, headers, body }));
|
|
125
|
+
const payload = await response.json().catch(() => null);
|
|
126
|
+
if (!response.ok) {
|
|
127
|
+
const error = payload as { error?: unknown; message?: unknown; reason?: unknown } | null;
|
|
128
|
+
throw new CoffreError(
|
|
129
|
+
response.status,
|
|
130
|
+
typeof error?.error === 'string' ? error.error : 'http_error',
|
|
131
|
+
typeof error?.message === 'string' ? error.message : `request failed with status ${response.status}`,
|
|
132
|
+
typeof error?.reason === 'string' ? error.reason : undefined,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return payload;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A route key and its parameters as a method and a path. */
|
|
139
|
+
function address<K extends RouteKey>(key: K, params: Params<K>): [method: string, path: string] {
|
|
140
|
+
const [method, pattern] = key.split(' ') as [string, string];
|
|
141
|
+
const path = pattern.replace(/:(\w+)/g, (_, name: string) =>
|
|
142
|
+
encodeURIComponent((params as Record<string, string>)[name]),
|
|
143
|
+
);
|
|
144
|
+
return [method, path];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Any route by its key: `call('GET /secrets/:project/:environment', { project, environment })`. */
|
|
148
|
+
async function call<K extends RouteKey>(key: K, ...[params, input]: Args<K>): Promise<RouteOutput<K>> {
|
|
149
|
+
const [method, path] = address(key, params);
|
|
150
|
+
return (await send(method, path, input)) as RouteOutput<K>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
call,
|
|
155
|
+
|
|
156
|
+
/** How this instance signs people in. Answers anyone, signed in or not. */
|
|
157
|
+
auth: () => send('GET', '/auth', undefined) as Promise<AuthInfo>,
|
|
158
|
+
|
|
159
|
+
/** Who I am, and every place I can reach. */
|
|
160
|
+
me: () => call('GET /me', {}),
|
|
161
|
+
|
|
162
|
+
projects: {
|
|
163
|
+
/** With their environments. */
|
|
164
|
+
list: () => call('GET /projects', {}),
|
|
165
|
+
create: (project: string, input: RouteInput<'PUT /projects/:project'>) =>
|
|
166
|
+
call('PUT /projects/:project', { project }, input),
|
|
167
|
+
update: (project: string, patch: RouteInput<'PATCH /projects/:project'>) =>
|
|
168
|
+
call('PATCH /projects/:project', { project }, patch),
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
environments: {
|
|
172
|
+
create: (path: string, input: RouteInput<'PUT /projects/:project/:environment'>) =>
|
|
173
|
+
call('PUT /projects/:project/:environment', place(path), input),
|
|
174
|
+
update: (path: string, patch: RouteInput<'PATCH /projects/:project/:environment'>) =>
|
|
175
|
+
call('PATCH /projects/:project/:environment', place(path), patch),
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
secrets: {
|
|
179
|
+
/** Keys, versions and who changed what; never values. */
|
|
180
|
+
list: (path: string) => call('GET /secrets/:project/:environment', place(path)),
|
|
181
|
+
/** Decrypts one secret, or every secret in an environment. Logged in your name. */
|
|
182
|
+
reveal: (path: string) => call('POST /reveals', {}, { path }),
|
|
183
|
+
/** One transaction, a version and an audit entry per key; `null` archives. */
|
|
184
|
+
set: (path: string, values: RouteInput<'PATCH /secrets/:project/:environment'>) =>
|
|
185
|
+
call('PATCH /secrets/:project/:environment', place(path), values) as Promise<SetResult>,
|
|
186
|
+
/**
|
|
187
|
+
* What `set` would do to each key, `added`, `changed`, `unchanged` or
|
|
188
|
+
* `archived`, and nothing else: no value comes back and nothing is
|
|
189
|
+
* written. Comparing opens the current values, logged as reads.
|
|
190
|
+
*/
|
|
191
|
+
dryRun: (path: string, values: RouteInput<'PATCH /secrets/:project/:environment'>) => {
|
|
192
|
+
const [method, route] = address('PATCH /secrets/:project/:environment', place(path));
|
|
193
|
+
return send(method, `${route}?dryRun=1`, values) as Promise<DryRunResult>;
|
|
194
|
+
},
|
|
195
|
+
history: (path: string) => call('GET /secrets/:project/:environment/:key/versions', place(path)),
|
|
196
|
+
/** A new version holding the old one's value. */
|
|
197
|
+
restore: (path: string, version: number) =>
|
|
198
|
+
call('POST /secrets/:project/:environment/:key/restore', place(path), { version }),
|
|
199
|
+
rename: (path: string, key: string) =>
|
|
200
|
+
call('PATCH /secrets/:project/:environment/:key', place(path), { key }),
|
|
201
|
+
update: (path: string, patch: RouteInput<'PATCH /secrets/:project/:environment/:key'>) =>
|
|
202
|
+
call('PATCH /secrets/:project/:environment/:key', place(path), patch),
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
members: {
|
|
206
|
+
/** People and tokens, their role and their access; at a place, those who reach it. */
|
|
207
|
+
list: (path?: string) => call('GET /members', {}, { path }),
|
|
208
|
+
/** What they hold, and what to rotate if they leave. */
|
|
209
|
+
get: (member: string) => call('GET /members/:member', { member }),
|
|
210
|
+
add: (member: string, input: RouteInput<'PUT /members/:member'> = {}) =>
|
|
211
|
+
call('PUT /members/:member', { member }, input),
|
|
212
|
+
/** Offboards; returns what to rotate. */
|
|
213
|
+
remove: (member: string) => call('DELETE /members/:member', { member }),
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
tokens: {
|
|
217
|
+
list: (member: string) => call('GET /members/:member/tokens', { member }),
|
|
218
|
+
/** The value is in the answer, and only there. */
|
|
219
|
+
issue: (member: string, input: RouteInput<'POST /members/:member/tokens'>) =>
|
|
220
|
+
call('POST /members/:member/tokens', { member }, input),
|
|
221
|
+
revoke: (member: string, id: string) => call('DELETE /members/:member/tokens/:id', { member, id }),
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
access: {
|
|
225
|
+
/** What they should hold at each place; the server applies the difference. `null` revokes. */
|
|
226
|
+
set: (member: string, access: RouteInput<'PATCH /access/:member'>) =>
|
|
227
|
+
call('PATCH /access/:member', { member }, access),
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/** Where I am signed in. Only where coffre runs its own sign-in. */
|
|
231
|
+
sessions: {
|
|
232
|
+
list: () => call('GET /sessions', {}),
|
|
233
|
+
revoke: (id: string) => call('DELETE /sessions/:id', { id }),
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
/** The accounts I sign in with. */
|
|
237
|
+
identities: {
|
|
238
|
+
list: () => call('GET /identities', {}),
|
|
239
|
+
unlink: (id: string) => call('DELETE /identities/:id', { id }),
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
/** A `coffre login` waiting for someone to approve it, by the code it shows. */
|
|
243
|
+
deviceLogins: {
|
|
244
|
+
get: (code: string) => call('GET /device-logins/:code', { code }),
|
|
245
|
+
decide: (code: string, approve: boolean) => call('POST /device-logins/:code', { code }, { approve }),
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
syncs: {
|
|
249
|
+
/** Where a sync can push on this instance, and what each asks for. */
|
|
250
|
+
providers: () => call('GET /syncs/providers', {}),
|
|
251
|
+
list: (path: string) => call('GET /syncs/:project/:environment', place(path)),
|
|
252
|
+
add: (path: string, input: RouteInput<'POST /syncs/:project/:environment'>) =>
|
|
253
|
+
call('POST /syncs/:project/:environment', place(path), input),
|
|
254
|
+
update: (id: string, patch: RouteInput<'PATCH /syncs/by-id/:id'>) =>
|
|
255
|
+
call('PATCH /syncs/by-id/:id', { id }, patch),
|
|
256
|
+
remove: (id: string) => call('DELETE /syncs/by-id/:id', { id }),
|
|
257
|
+
run: (id: string) => call('POST /syncs/by-id/:id/runs', { id }),
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
audit: {
|
|
261
|
+
list: (query: RouteInput<'GET /audit'> = {}) => call('GET /audit', {}, query),
|
|
262
|
+
verify: () => call('GET /audit/verification', {}),
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export type CoffreClient = ReturnType<typeof createClient>;
|
|
268
|
+
|
|
269
|
+
export type ImportAction = Exclude<DryRunOutcome, 'archived'>;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* What writing these entries (a parsed `.env` file) would do, from a dry run
|
|
273
|
+
* of the write: the server compares, so no value leaves it, and the values it
|
|
274
|
+
* opens to compare are logged as reads. `changes` is what to pass to
|
|
275
|
+
* `secrets.set`: the keys that differ, so an unchanged value does not become
|
|
276
|
+
* a new version.
|
|
277
|
+
*/
|
|
278
|
+
export async function planImport(
|
|
279
|
+
coffre: CoffreClient,
|
|
280
|
+
path: string,
|
|
281
|
+
entries: readonly { key: string; value: string }[],
|
|
282
|
+
): Promise<{
|
|
283
|
+
plan: { key: string; action: ImportAction; version: number | null }[];
|
|
284
|
+
changes: Record<string, string>;
|
|
285
|
+
}> {
|
|
286
|
+
// Null-prototype records: a key named __proto__ is a key like any other.
|
|
287
|
+
const values: Record<string, string> = Object.create(null);
|
|
288
|
+
for (const { key, value } of entries) values[key] = value;
|
|
289
|
+
// Settle both before throwing, so a refusal leaves no call still in flight.
|
|
290
|
+
const [listed, compared] = await Promise.allSettled([
|
|
291
|
+
coffre.secrets.list(path),
|
|
292
|
+
coffre.secrets.dryRun(path, { ...values }),
|
|
293
|
+
]);
|
|
294
|
+
if (listed.status === 'rejected') throw listed.reason;
|
|
295
|
+
if (compared.status === 'rejected') throw compared.reason;
|
|
296
|
+
const [{ keys }, dryRun] = [listed.value, compared.value];
|
|
297
|
+
const versions = new Map(keys.map((entry) => [entry.key, entry.version]));
|
|
298
|
+
const plan: { key: string; action: ImportAction; version: number | null }[] = [];
|
|
299
|
+
const changes: Record<string, string> = Object.create(null);
|
|
300
|
+
for (const [key, value] of Object.entries(values)) {
|
|
301
|
+
// A patch without nulls archives nothing, so every outcome is one of these.
|
|
302
|
+
const action = dryRun.keys[key] as ImportAction;
|
|
303
|
+
plan.push({ key, action, version: versions.get(key) ?? null });
|
|
304
|
+
if (action !== 'unchanged') changes[key] = value;
|
|
305
|
+
}
|
|
306
|
+
return { plan, changes: { ...changes } };
|
|
307
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sync provider's form, as the web page and `coffre sync add` fill it in.
|
|
3
|
+
*
|
|
4
|
+
* Each provider describes its fields (`GET /syncs/providers`); the server's
|
|
5
|
+
* parser validates every one of them (formats, lengths, combinations) and
|
|
6
|
+
* says what is wrong in a sentence. This only turns what someone typed into
|
|
7
|
+
* the JSON that parser expects.
|
|
8
|
+
*/
|
|
9
|
+
import type { SyncField, SyncProviderInfo } from './api.ts';
|
|
10
|
+
|
|
11
|
+
export type FormValues = Record<string, string | string[]>;
|
|
12
|
+
|
|
13
|
+
export function initialValues(provider: SyncProviderInfo): FormValues {
|
|
14
|
+
return Object.fromEntries(
|
|
15
|
+
provider.fields.map((field) => [field.name, field.type === 'text' ? '' : field.initial]),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Whether the rest of the form makes the field meaningful. */
|
|
20
|
+
export function isAsked(field: SyncField, values: FormValues): boolean {
|
|
21
|
+
if (field.type === 'options' || field.when === undefined) return true;
|
|
22
|
+
const picked = values[field.when.field];
|
|
23
|
+
const list = Array.isArray(picked) ? picked : [];
|
|
24
|
+
return list.length === field.when.is.length && field.when.is.every((value) => list.includes(value));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The label of the first required field left empty, or null when the form can be sent. */
|
|
28
|
+
export function firstMissing(provider: SyncProviderInfo, values: FormValues): string | null {
|
|
29
|
+
for (const field of provider.fields) {
|
|
30
|
+
if (!isAsked(field, values)) continue;
|
|
31
|
+
const value = values[field.name];
|
|
32
|
+
const empty = typeof value === 'string' ? value.trim() === '' : (value ?? []).length === 0;
|
|
33
|
+
if (empty && (field.type === 'options' || field.optional !== true)) return field.label;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The provider's config. Text is trimmed, and an optional field left empty
|
|
40
|
+
* is left out rather than sent as "", which the server rightly refuses.
|
|
41
|
+
*/
|
|
42
|
+
export function configFromForm(provider: SyncProviderInfo, values: FormValues): Record<string, unknown> {
|
|
43
|
+
const config: Record<string, unknown> = {};
|
|
44
|
+
for (const field of provider.fields) {
|
|
45
|
+
if (!isAsked(field, values)) continue;
|
|
46
|
+
const value = values[field.name];
|
|
47
|
+
if (field.type === 'options') {
|
|
48
|
+
const picked = Array.isArray(value) ? value : [];
|
|
49
|
+
config[field.name] = field.multiple ? picked : picked[0];
|
|
50
|
+
} else {
|
|
51
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
52
|
+
if (text !== '') config[field.name] = text;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return config;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The config from `name=value` arguments, as the CLI takes them. A field with
|
|
60
|
+
* several options takes them comma-separated, and one left out gets what the
|
|
61
|
+
* form preselects. Text goes as typed, for the server to check.
|
|
62
|
+
*/
|
|
63
|
+
export function configFromArguments(provider: SyncProviderInfo, assignments: readonly string[]): Record<string, unknown> {
|
|
64
|
+
const config: Record<string, unknown> = {};
|
|
65
|
+
for (const assignment of assignments) {
|
|
66
|
+
const equals = assignment.indexOf('=');
|
|
67
|
+
const name = equals === -1 ? assignment : assignment.slice(0, equals);
|
|
68
|
+
const field = provider.fields.find((candidate) => candidate.name === name);
|
|
69
|
+
if (equals === -1 || field === undefined) {
|
|
70
|
+
const names = provider.fields.map((candidate) => candidate.name).join(', ');
|
|
71
|
+
throw new Error(`${provider.label} takes ${names} as name=value; got ${JSON.stringify(assignment)}`);
|
|
72
|
+
}
|
|
73
|
+
if (Object.hasOwn(config, name)) throw new Error(`${name} is given twice`);
|
|
74
|
+
const value = assignment.slice(equals + 1);
|
|
75
|
+
if (field.type === 'text') config[name] = value;
|
|
76
|
+
else if (field.multiple) config[name] = value.split(',').map((item) => item.trim()).filter((item) => item !== '');
|
|
77
|
+
else config[name] = value;
|
|
78
|
+
}
|
|
79
|
+
for (const field of provider.fields) {
|
|
80
|
+
if (field.type === 'options' && !Object.hasOwn(config, field.name)) {
|
|
81
|
+
config[field.name] = field.multiple ? field.initial : field.initial[0];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return config;
|
|
85
|
+
}
|