@credda/cli 0.1.0 → 0.1.2
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/README.md +136 -1
- package/dist/cli.d.ts +50 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +701 -5
- package/dist/cli.js.map +1 -1
- package/dist/cli.test.js +626 -1
- package/dist/cli.test.js.map +1 -1
- package/dist/index.js +24 -1
- package/dist/index.js.map +1 -1
- package/package.json +32 -32
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* looks up and offline-verifies EXISTING, already-computed trust facts.
|
|
8
8
|
* `mint`/`revoke` manage a share token (a capability, not a score write).
|
|
9
9
|
*/
|
|
10
|
-
export const VERSION = '0.
|
|
10
|
+
export const VERSION = '0.1.2';
|
|
11
11
|
export const HELP = `credda — portable trust from the terminal
|
|
12
12
|
|
|
13
13
|
Public (no API key):
|
|
@@ -18,22 +18,133 @@ Public (no API key):
|
|
|
18
18
|
export bundle (auto-detected). '-' = stdin.
|
|
19
19
|
credda registry Federated trust registry
|
|
20
20
|
credda did Issuer DID document
|
|
21
|
+
credda benchmarks Cohort-benchmark catalog (dimensions + k-anonymity)
|
|
22
|
+
credda reason-codes Adverse-action reason-code catalog (ECOA / Reg B)
|
|
23
|
+
credda badges list Open Badges 3.0 achievements this issuer signs
|
|
24
|
+
credda badges get <badgeId> One achievement definition
|
|
25
|
+
credda professional-record public <token>
|
|
26
|
+
The professional record behind a share token
|
|
27
|
+
(the subject's own consent to present it)
|
|
21
28
|
|
|
22
29
|
Platform (needs CREDDA_API_KEY):
|
|
23
30
|
credda score <userId> Current score
|
|
24
31
|
credda explain <userId> Factor-level score explanation
|
|
25
32
|
credda components <userId> Six named 0-100 score components
|
|
26
33
|
credda risk <userId> Advisory risk signals
|
|
27
|
-
credda
|
|
34
|
+
credda trust-summary <userId> [--narrative]
|
|
35
|
+
Deterministic, evidence-based trust summary
|
|
36
|
+
(explains; never a verdict). --narrative adds an
|
|
37
|
+
advisory AI retelling when the server has AI on.
|
|
38
|
+
credda benchmark <userId> [--dimension <d>]
|
|
39
|
+
Where a subject sits within its cohort:
|
|
40
|
+
percentile + the cohort distribution
|
|
41
|
+
credda distribution [--dimension <d>] [--cohort <c>]
|
|
42
|
+
Aggregate, k-anonymised cohort distribution.
|
|
43
|
+
Omit --cohort for every cohort on the dimension.
|
|
44
|
+
credda users [--score-min <n>] [--score-max <n>] [--band <b>] [--subject-type <PERSON|AGENT>]
|
|
45
|
+
[--active-since <iso>] [--verified] [--min-verified <n>]
|
|
46
|
+
[--sort <score|lastActivity|registered|externalId>] [--order <asc|desc>]
|
|
47
|
+
[--cursor <c>] [--limit <n>]
|
|
48
|
+
Query + export your book of subjects
|
|
49
|
+
credda usage [days] [--from <date> --to <date>] [--csv <outfile>]
|
|
50
|
+
Your platform's metered API usage. Either a
|
|
51
|
+
trailing [days] window OR an inclusive
|
|
52
|
+
--from/--to date range (YYYY-MM-DD), not both.
|
|
53
|
+
--csv writes the flat CSV statement to a file.
|
|
54
|
+
credda activity [--action <A>] [--from <t> --to <t>] [--cursor <c>] [--limit <n>]
|
|
55
|
+
Your platform's own activity/audit log,
|
|
56
|
+
newest-first, cursor-paginated
|
|
57
|
+
credda verified-profile <userId>
|
|
58
|
+
How much of a subject's CLAIMED record
|
|
59
|
+
(education/skills/certifications/employment) is
|
|
60
|
+
third-party verified. Counts whether a claim is
|
|
61
|
+
verified, never how prestigious it is — and it
|
|
62
|
+
can never move the Reliability Score.
|
|
63
|
+
credda qualify <userId> --category <education|skill|certification|employment>
|
|
64
|
+
[--label <l>] [--issuer <i>] [--verified-by <witness>]
|
|
65
|
+
Record a qualification claim. Always recorded;
|
|
66
|
+
counts as VERIFIED only with a genuine
|
|
67
|
+
third-party --verified-by witness.
|
|
68
|
+
credda professional-record get <userId>
|
|
69
|
+
Résumé-shaped summary of a VERIFIED work record.
|
|
70
|
+
Describes a record — not a hiring verdict, a
|
|
71
|
+
background check, or a consumer report.
|
|
72
|
+
credda professional-record credential <userId> [--ttl <seconds>]
|
|
73
|
+
Mint the signed, offline-verifiable Professional
|
|
74
|
+
Record Credential (+ an "Add to LinkedIn" link)
|
|
28
75
|
credda mint <userId> Mint a share token for a user
|
|
29
76
|
credda revoke <userId> Revoke a user's share token
|
|
30
77
|
|
|
78
|
+
Confirmation requests — the counterparty-confirmation primitive. You PROPOSE an
|
|
79
|
+
outcome and deliver the one-time token to the counterparty over YOUR OWN channel;
|
|
80
|
+
the event is written, verified, only when that distinct party confirms:
|
|
81
|
+
credda confirmations create --user <externalId> --type <eventType>
|
|
82
|
+
--counterparty <ref> [--counterparty-name <n>] [--description <d>]
|
|
83
|
+
[--stake <HIGH|MEDIUM|LOW>] [--value <n>] [--due <iso>] [--completed <iso>]
|
|
84
|
+
[--return-url <url>] [--expires-in <days>] [--idempotency-key <k>]
|
|
85
|
+
Needs CREDDA_API_KEY. Token shown ONCE.
|
|
86
|
+
credda confirmations list [--status <s>] [--cursor <c>] [--limit <n>]
|
|
87
|
+
credda confirmations get <id>
|
|
88
|
+
credda confirmations cancel <id>
|
|
89
|
+
credda confirmations preview <id> --token <t>
|
|
90
|
+
What the counterparty is asked to confirm.
|
|
91
|
+
NO API key — the token is the capability.
|
|
92
|
+
credda confirmations respond <id> --token <t> (--confirm | --decline)
|
|
93
|
+
The counterparty's decision. NO API key.
|
|
94
|
+
--confirm writes the event; --decline writes
|
|
95
|
+
nothing. Single-use either way.
|
|
96
|
+
|
|
97
|
+
Threshold policies (needs CREDDA_API_KEY) — declarative "tell me when this line
|
|
98
|
+
is crossed"; delivers policy.threshold_crossed through your webhooks. Config
|
|
99
|
+
only: a policy never reads into, blocks, or changes a score:
|
|
100
|
+
credda policies list [--cursor <c>] [--limit <n>]
|
|
101
|
+
credda policies get <id>
|
|
102
|
+
credda policies create --name <n> (--user <externalId> | --all)
|
|
103
|
+
--metric <score|component|band|verified_events>
|
|
104
|
+
[--direction <up|down|enter|leave>] [--threshold <n>]
|
|
105
|
+
[--component <reliability|timeliness|trustworthiness|verification|consistency|momentum>]
|
|
106
|
+
[--band <b>]
|
|
107
|
+
credda policies update <id> [--name <n>] [--direction <d>] [--threshold <n>]
|
|
108
|
+
[--component <c>] [--band <b>] [--activate | --deactivate]
|
|
109
|
+
The metric is immutable — delete + recreate.
|
|
110
|
+
credda policies delete <id>
|
|
111
|
+
|
|
112
|
+
Score monitors (needs CREDDA_API_KEY) — edge-triggered watches that deliver
|
|
113
|
+
"monitor.triggered" through your webhooks; notification config only, a
|
|
114
|
+
monitor never affects a score:
|
|
115
|
+
credda monitors list [--cursor <c>] [--limit <n>]
|
|
116
|
+
credda monitors get <id>
|
|
117
|
+
credda monitors create --user <externalId> [--below <score>] [--above <score>] [--band-change]
|
|
118
|
+
At least one condition required. --below fires
|
|
119
|
+
on a downward crossing (and on a first score
|
|
120
|
+
already below it), --above on an upward
|
|
121
|
+
crossing, --band-change on any band change.
|
|
122
|
+
credda monitors delete <id>
|
|
123
|
+
|
|
124
|
+
Bulk screenings (needs CREDDA_API_KEY) — async batch score reads, up to
|
|
125
|
+
10,000 ids per job, strictly read-only:
|
|
126
|
+
credda screen <ids...> Submit ids (comma/space separated), or:
|
|
127
|
+
credda screen --file <path> One id per line, or a CSV whose FIRST column
|
|
128
|
+
is the id (a leading "id"/"userId"/
|
|
129
|
+
"externalId" header row is skipped).
|
|
130
|
+
[--wait] Poll until the job finishes, then print the
|
|
131
|
+
summary (exit 1 if the job FAILED).
|
|
132
|
+
credda screenings list [--cursor <c>] [--limit <n>]
|
|
133
|
+
credda screenings get <id> Job status + summary
|
|
134
|
+
credda screenings results <id> [--csv <outfile>]
|
|
135
|
+
Per-user results (JSON; --csv writes the CSV
|
|
136
|
+
attachment to a file instead)
|
|
137
|
+
|
|
31
138
|
Webhooks (needs CREDDA_API_KEY):
|
|
32
139
|
credda webhooks list Your webhook subscriptions
|
|
33
140
|
credda webhooks create <url> <event..> Subscribe (secret shown ONCE)
|
|
34
141
|
credda webhooks delete <id> Remove a webhook
|
|
35
142
|
credda webhooks test <id> Send a synthetic signed delivery
|
|
36
143
|
credda webhooks deliveries <id> Recent delivery attempts (incl. retries)
|
|
144
|
+
credda webhooks recent [event..] Recent events across ALL your endpoints
|
|
145
|
+
(sample data for automation platforms;
|
|
146
|
+
falls back to catalog examples, flagged
|
|
147
|
+
isExample, when nothing has fired yet)
|
|
37
148
|
|
|
38
149
|
Local development:
|
|
39
150
|
credda listen [port] Local webhook receiver: verifies each delivery's
|
|
@@ -63,6 +174,134 @@ function requireArg(args, name) {
|
|
|
63
174
|
function show(ctx, value) {
|
|
64
175
|
ctx.out(JSON.stringify(value, null, 2));
|
|
65
176
|
}
|
|
177
|
+
/**
|
|
178
|
+
* Extra stderr lines for a failed command.
|
|
179
|
+
*
|
|
180
|
+
* The important one is the **request id**: it is the single fastest way for
|
|
181
|
+
* Credda to diagnose a failure, and a CLI user has nowhere else to find it.
|
|
182
|
+
* Also surfaces the machine code (so it can be looked up in
|
|
183
|
+
* `GET /api/v1/errors`) and any `Retry-After` the server asked for.
|
|
184
|
+
*
|
|
185
|
+
* Duck-typed rather than `instanceof CreddaError` on purpose — the router
|
|
186
|
+
* imports only TYPES from the SDK, so it stays pure and trivially mockable.
|
|
187
|
+
* Pure and exported for testing.
|
|
188
|
+
*/
|
|
189
|
+
export function errorHints(e) {
|
|
190
|
+
if (!e || typeof e !== 'object')
|
|
191
|
+
return [];
|
|
192
|
+
const err = e;
|
|
193
|
+
const lines = [];
|
|
194
|
+
if (typeof err.code === 'string' && err.code) {
|
|
195
|
+
lines.push(` code: ${err.code} (see https://api.credda.io/api/v1/errors)`);
|
|
196
|
+
}
|
|
197
|
+
if (typeof err.requestId === 'string' && err.requestId) {
|
|
198
|
+
lines.push(` requestId: ${err.requestId} (quote this to support)`);
|
|
199
|
+
}
|
|
200
|
+
if (typeof err.retryAfterMs === 'number' && err.retryAfterMs > 0) {
|
|
201
|
+
lines.push(` retry in: ${Math.ceil(err.retryAfterMs / 1000)}s`);
|
|
202
|
+
}
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Tiny flag parser: `--name value` for valued flags, bare `--name` for
|
|
207
|
+
* booleans, everything else positional. Unknown `--flags` are an error rather
|
|
208
|
+
* than silently becoming positionals.
|
|
209
|
+
*/
|
|
210
|
+
export function parseFlags(args, spec = {}) {
|
|
211
|
+
const valued = new Set(spec.valued ?? []);
|
|
212
|
+
const bools = new Set(spec.boolean ?? []);
|
|
213
|
+
const positional = [];
|
|
214
|
+
const flags = {};
|
|
215
|
+
for (let i = 0; i < args.length; i++) {
|
|
216
|
+
const a = args[i];
|
|
217
|
+
if (a.startsWith('--')) {
|
|
218
|
+
const name = a.slice(2);
|
|
219
|
+
if (bools.has(name)) {
|
|
220
|
+
flags[name] = true;
|
|
221
|
+
}
|
|
222
|
+
else if (valued.has(name)) {
|
|
223
|
+
const v = args[++i];
|
|
224
|
+
if (v === undefined)
|
|
225
|
+
throw new Error(`--${name} needs a value — see "credda help"`);
|
|
226
|
+
flags[name] = v;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
throw new Error(`unknown flag --${name} — see "credda help"`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
positional.push(a);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return { positional, flags };
|
|
237
|
+
}
|
|
238
|
+
function numFlag(flags, name) {
|
|
239
|
+
const v = flags[name];
|
|
240
|
+
if (v === undefined)
|
|
241
|
+
return undefined;
|
|
242
|
+
const n = Number(v);
|
|
243
|
+
if (typeof v !== 'string' || v === '' || !Number.isFinite(n)) {
|
|
244
|
+
throw new Error(`--${name} must be a number`);
|
|
245
|
+
}
|
|
246
|
+
return n;
|
|
247
|
+
}
|
|
248
|
+
function intFlag(flags, name) {
|
|
249
|
+
const n = numFlag(flags, name);
|
|
250
|
+
if (n !== undefined && (!Number.isInteger(n) || n < 1)) {
|
|
251
|
+
throw new Error(`--${name} must be a positive integer`);
|
|
252
|
+
}
|
|
253
|
+
return n;
|
|
254
|
+
}
|
|
255
|
+
function strFlag(flags, name) {
|
|
256
|
+
const v = flags[name];
|
|
257
|
+
return typeof v === 'string' ? v : undefined;
|
|
258
|
+
}
|
|
259
|
+
/** `--cursor` / `--limit` shared by the paginated list commands. */
|
|
260
|
+
function pageQuery(flags) {
|
|
261
|
+
return { limit: intFlag(flags, 'limit'), cursor: strFlag(flags, 'cursor') };
|
|
262
|
+
}
|
|
263
|
+
const ID_HEADER_NAMES = /^(id|userid|user_id|externalid|external_id)$/i;
|
|
264
|
+
/**
|
|
265
|
+
* Parse the ids for `credda screen`. Inline args may be comma- and/or
|
|
266
|
+
* space-separated. A file is one id per line — or a CSV, in which case only
|
|
267
|
+
* the FIRST column is read (a leading header row named id/userId/externalId
|
|
268
|
+
* is skipped). Deduped, order-preserving. Deliberately simple: no quoted-CSV
|
|
269
|
+
* handling — an id containing a comma isn't a valid external id anyway.
|
|
270
|
+
*/
|
|
271
|
+
export function parseIdList(input) {
|
|
272
|
+
const raw = [];
|
|
273
|
+
if (input.inline) {
|
|
274
|
+
for (const chunk of input.inline)
|
|
275
|
+
raw.push(...chunk.split(/[\s,]+/));
|
|
276
|
+
}
|
|
277
|
+
if (input.fileText !== undefined) {
|
|
278
|
+
const lines = input.fileText.split(/\r?\n/);
|
|
279
|
+
for (let i = 0; i < lines.length; i++) {
|
|
280
|
+
const cell = lines[i].split(',')[0].trim();
|
|
281
|
+
if (!cell)
|
|
282
|
+
continue;
|
|
283
|
+
if (i === 0 && ID_HEADER_NAMES.test(cell))
|
|
284
|
+
continue; // CSV header row
|
|
285
|
+
raw.push(cell);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
const ids = [];
|
|
290
|
+
for (const id of raw) {
|
|
291
|
+
const trimmed = id.trim();
|
|
292
|
+
if (trimmed && !seen.has(trimmed)) {
|
|
293
|
+
seen.add(trimmed);
|
|
294
|
+
ids.push(trimmed);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return ids;
|
|
298
|
+
}
|
|
299
|
+
function requireCsvIo(ctx) {
|
|
300
|
+
if (!ctx.fetchCsv || !ctx.writeFile) {
|
|
301
|
+
throw new Error('CSV output is not available in this environment');
|
|
302
|
+
}
|
|
303
|
+
return { fetchCsv: ctx.fetchCsv, writeFile: ctx.writeFile };
|
|
304
|
+
}
|
|
66
305
|
/** Classify verify input: trust-export bundle JSON, VC-JWT, or compact credential. */
|
|
67
306
|
export function classifyCredentialInput(raw) {
|
|
68
307
|
const trimmed = raw.trim();
|
|
@@ -131,6 +370,32 @@ export async function runCli(argv, ctx) {
|
|
|
131
370
|
case 'did':
|
|
132
371
|
show(ctx, await ctx.client.getDidDocument());
|
|
133
372
|
return 0;
|
|
373
|
+
case 'benchmarks':
|
|
374
|
+
// Public catalog: cohort dimensions + the k-anonymity floor. A benchmark
|
|
375
|
+
// is a distribution fact, never a verdict.
|
|
376
|
+
show(ctx, await ctx.client.getBenchmarks());
|
|
377
|
+
return 0;
|
|
378
|
+
case 'reason-codes':
|
|
379
|
+
// Public adverse-action reason-code catalog (ECOA / Reg B). Credda
|
|
380
|
+
// supplies the attribution only — it is not a creditor.
|
|
381
|
+
show(ctx, await ctx.client.getReasonCodes());
|
|
382
|
+
return 0;
|
|
383
|
+
case 'badges': {
|
|
384
|
+
// Public: the closed set of Open Badges 3.0 achievements this issuer
|
|
385
|
+
// will sign. A verifier reads the criteria from the ISSUER rather than
|
|
386
|
+
// trusting the narrative inside the document it is checking.
|
|
387
|
+
const [sub, ...rest] = args;
|
|
388
|
+
switch (sub) {
|
|
389
|
+
case 'list':
|
|
390
|
+
show(ctx, await ctx.client.getOpenBadgeAchievements());
|
|
391
|
+
return 0;
|
|
392
|
+
case 'get':
|
|
393
|
+
show(ctx, await ctx.client.getOpenBadgeAchievement(requireArg(rest, 'badgeId')));
|
|
394
|
+
return 0;
|
|
395
|
+
default:
|
|
396
|
+
throw new Error(`unknown badges subcommand "${sub ?? ''}" — see "credda help"`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
134
399
|
case 'score':
|
|
135
400
|
show(ctx, await ctx.client.getScore(requireArg(args, 'userId'), requireKey(ctx)));
|
|
136
401
|
return 0;
|
|
@@ -143,14 +408,436 @@ export async function runCli(argv, ctx) {
|
|
|
143
408
|
case 'risk':
|
|
144
409
|
show(ctx, await ctx.client.getRisk(requireArg(args, 'userId'), requireKey(ctx)));
|
|
145
410
|
return 0;
|
|
411
|
+
case 'trust-summary': {
|
|
412
|
+
const { positional, flags } = parseFlags(args, { boolean: ['narrative'] });
|
|
413
|
+
const userId = requireArg(positional, 'userId');
|
|
414
|
+
show(ctx, await ctx.client.getTrustSummary(userId, requireKey(ctx), {
|
|
415
|
+
narrative: flags.narrative === true,
|
|
416
|
+
}));
|
|
417
|
+
return 0;
|
|
418
|
+
}
|
|
419
|
+
case 'benchmark': {
|
|
420
|
+
// Where one subject sits within a cohort — percentile + distribution.
|
|
421
|
+
const { positional, flags } = parseFlags(args, { valued: ['dimension'] });
|
|
422
|
+
const userId = requireArg(positional, 'userId');
|
|
423
|
+
show(ctx, await ctx.client.getUserBenchmark(userId, requireKey(ctx), {
|
|
424
|
+
dimension: strFlag(flags, 'dimension'),
|
|
425
|
+
}));
|
|
426
|
+
return 0;
|
|
427
|
+
}
|
|
428
|
+
case 'distribution': {
|
|
429
|
+
// Aggregate, k-anonymised cohort distribution (or a whole dimension).
|
|
430
|
+
const { positional, flags } = parseFlags(args, { valued: ['dimension', 'cohort'] });
|
|
431
|
+
if (positional.length > 0) {
|
|
432
|
+
throw new Error(`distribution takes no positional arguments (got "${positional[0]}") — see "credda help"`);
|
|
433
|
+
}
|
|
434
|
+
show(ctx, await ctx.client.getBenchmarkDistribution(requireKey(ctx), {
|
|
435
|
+
dimension: strFlag(flags, 'dimension'),
|
|
436
|
+
cohort: strFlag(flags, 'cohort'),
|
|
437
|
+
}));
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
case 'users': {
|
|
441
|
+
// Query + export your book of subjects (closed filter set).
|
|
442
|
+
const { positional, flags } = parseFlags(args, {
|
|
443
|
+
valued: [
|
|
444
|
+
'score-min', 'score-max', 'band', 'subject-type', 'active-since',
|
|
445
|
+
'min-verified', 'sort', 'order', 'cursor', 'limit',
|
|
446
|
+
],
|
|
447
|
+
boolean: ['verified'],
|
|
448
|
+
});
|
|
449
|
+
if (positional.length > 0) {
|
|
450
|
+
throw new Error(`users takes no positional arguments (got "${positional[0]}") — see "credda help"`);
|
|
451
|
+
}
|
|
452
|
+
const query = {
|
|
453
|
+
scoreMin: numFlag(flags, 'score-min'),
|
|
454
|
+
scoreMax: numFlag(flags, 'score-max'),
|
|
455
|
+
band: strFlag(flags, 'band'),
|
|
456
|
+
subjectType: strFlag(flags, 'subject-type'),
|
|
457
|
+
activeSince: strFlag(flags, 'active-since'),
|
|
458
|
+
minVerifiedEvents: intFlag(flags, 'min-verified'),
|
|
459
|
+
sort: strFlag(flags, 'sort'),
|
|
460
|
+
order: strFlag(flags, 'order'),
|
|
461
|
+
...pageQuery(flags),
|
|
462
|
+
};
|
|
463
|
+
if (flags.verified === true)
|
|
464
|
+
query.hasVerifiedEvents = true;
|
|
465
|
+
show(ctx, await ctx.client.listUsers(requireKey(ctx), query));
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
146
468
|
case 'usage': {
|
|
147
|
-
const
|
|
469
|
+
const { positional, flags } = parseFlags(args, { valued: ['from', 'to', 'csv'] });
|
|
470
|
+
const days = positional[0] ? Number(positional[0]) : undefined;
|
|
148
471
|
if (days !== undefined && (!Number.isInteger(days) || days < 1)) {
|
|
149
472
|
throw new Error('usage [days] — days must be a positive integer');
|
|
150
473
|
}
|
|
151
|
-
|
|
474
|
+
const from = strFlag(flags, 'from');
|
|
475
|
+
const to = strFlag(flags, 'to');
|
|
476
|
+
if (days !== undefined && (from || to)) {
|
|
477
|
+
throw new Error('usage — use either a [days] window or --from/--to, not both');
|
|
478
|
+
}
|
|
479
|
+
const key = requireKey(ctx);
|
|
480
|
+
const outfile = strFlag(flags, 'csv');
|
|
481
|
+
if (outfile) {
|
|
482
|
+
const { fetchCsv, writeFile } = requireCsvIo(ctx);
|
|
483
|
+
const qs = new URLSearchParams({ format: 'csv' });
|
|
484
|
+
if (days !== undefined)
|
|
485
|
+
qs.set('days', String(days));
|
|
486
|
+
if (from)
|
|
487
|
+
qs.set('from', from);
|
|
488
|
+
if (to)
|
|
489
|
+
qs.set('to', to);
|
|
490
|
+
await writeFile(outfile, await fetchCsv(`/usage?${qs.toString()}`, key));
|
|
491
|
+
show(ctx, { written: outfile });
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
const window = from || to ? { from, to } : days;
|
|
495
|
+
show(ctx, await ctx.client.getUsage(key, window));
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
498
|
+
case 'activity': {
|
|
499
|
+
const { positional, flags } = parseFlags(args, {
|
|
500
|
+
valued: ['action', 'from', 'to', 'cursor', 'limit'],
|
|
501
|
+
});
|
|
502
|
+
if (positional.length > 0) {
|
|
503
|
+
throw new Error(`activity takes no positional arguments (got "${positional[0]}") — see "credda help"`);
|
|
504
|
+
}
|
|
505
|
+
show(ctx, await ctx.client.getActivity(requireKey(ctx), {
|
|
506
|
+
...pageQuery(flags),
|
|
507
|
+
action: strFlag(flags, 'action'),
|
|
508
|
+
from: strFlag(flags, 'from'),
|
|
509
|
+
to: strFlag(flags, 'to'),
|
|
510
|
+
}));
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
case 'verified-profile':
|
|
514
|
+
// A SECOND measure over the same ledger — it can never move a score.
|
|
515
|
+
show(ctx, await ctx.client.getVerifiedProfile(requireArg(args, 'userId'), requireKey(ctx)));
|
|
516
|
+
return 0;
|
|
517
|
+
case 'qualify': {
|
|
518
|
+
// The claim is ALWAYS recorded; --verified-by decides whether it counts
|
|
519
|
+
// as verified. Never assert it yourself — name the witness.
|
|
520
|
+
const { positional, flags } = parseFlags(args, {
|
|
521
|
+
valued: ['category', 'label', 'issuer', 'verified-by'],
|
|
522
|
+
});
|
|
523
|
+
const userId = requireArg(positional, 'userId');
|
|
524
|
+
const category = strFlag(flags, 'category');
|
|
525
|
+
if (!category) {
|
|
526
|
+
throw new Error('qualify needs --category <education|skill|certification|employment>');
|
|
527
|
+
}
|
|
528
|
+
show(ctx, await ctx.client.recordQualification(userId, {
|
|
529
|
+
category: category,
|
|
530
|
+
label: strFlag(flags, 'label'),
|
|
531
|
+
issuer: strFlag(flags, 'issuer'),
|
|
532
|
+
verifiedBy: strFlag(flags, 'verified-by'),
|
|
533
|
+
}, requireKey(ctx)));
|
|
534
|
+
return 0;
|
|
535
|
+
}
|
|
536
|
+
case 'professional-record': {
|
|
537
|
+
const [sub, ...rest] = args;
|
|
538
|
+
switch (sub) {
|
|
539
|
+
case 'get':
|
|
540
|
+
show(ctx, await ctx.client.getProfessionalRecord(requireArg(rest, 'userId'), requireKey(ctx)));
|
|
541
|
+
return 0;
|
|
542
|
+
case 'credential': {
|
|
543
|
+
const { positional, flags } = parseFlags(rest, { valued: ['ttl'] });
|
|
544
|
+
const userId = requireArg(positional, 'userId');
|
|
545
|
+
const ttlSeconds = intFlag(flags, 'ttl');
|
|
546
|
+
const minted = await ctx.client.mintProfessionalRecordCredential(userId, requireKey(ctx), ttlSeconds !== undefined ? { ttlSeconds } : {});
|
|
547
|
+
show(ctx, minted);
|
|
548
|
+
return 0;
|
|
549
|
+
}
|
|
550
|
+
case 'public':
|
|
551
|
+
// Public: the token IS the subject's consent to present the record.
|
|
552
|
+
show(ctx, await ctx.client.getPublicProfessionalRecord(requireArg(rest, 'token')));
|
|
553
|
+
return 0;
|
|
554
|
+
default:
|
|
555
|
+
throw new Error(`unknown professional-record subcommand "${sub ?? ''}" — see "credda help"`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
case 'confirmations': {
|
|
559
|
+
// ⚠️ Auth is ASYMMETRIC here: preview/respond are the COUNTERPARTY's
|
|
560
|
+
// calls and take no API key at all, so requireKey() is deliberately
|
|
561
|
+
// scoped per-subcommand rather than hoisted for the whole group.
|
|
562
|
+
const [sub, ...rest] = args;
|
|
563
|
+
switch (sub) {
|
|
564
|
+
case 'create': {
|
|
565
|
+
const { flags } = parseFlags(rest, {
|
|
566
|
+
valued: [
|
|
567
|
+
'user', 'type', 'counterparty', 'counterparty-name', 'description',
|
|
568
|
+
'stake', 'value', 'due', 'completed', 'return-url', 'expires-in',
|
|
569
|
+
'idempotency-key',
|
|
570
|
+
],
|
|
571
|
+
});
|
|
572
|
+
const userId = strFlag(flags, 'user');
|
|
573
|
+
const eventType = strFlag(flags, 'type');
|
|
574
|
+
const counterpartyRef = strFlag(flags, 'counterparty');
|
|
575
|
+
if (!userId || !eventType || !counterpartyRef) {
|
|
576
|
+
throw new Error('confirmations create needs --user <externalId> (the subject), --type <eventType> and --counterparty <ref> (your key for the party being asked to confirm)');
|
|
577
|
+
}
|
|
578
|
+
const stake = strFlag(flags, 'stake');
|
|
579
|
+
const value = numFlag(flags, 'value');
|
|
580
|
+
const expiresInDays = intFlag(flags, 'expires-in');
|
|
581
|
+
const created = await ctx.client.createConfirmationRequest({
|
|
582
|
+
userId,
|
|
583
|
+
eventType: eventType,
|
|
584
|
+
counterpartyRef,
|
|
585
|
+
...(strFlag(flags, 'counterparty-name') ? { counterpartyName: strFlag(flags, 'counterparty-name') } : {}),
|
|
586
|
+
...(strFlag(flags, 'description') ? { description: strFlag(flags, 'description') } : {}),
|
|
587
|
+
...(stake ? { stakeLevel: stake } : {}),
|
|
588
|
+
...(value !== undefined ? { transactionValue: value } : {}),
|
|
589
|
+
...(strFlag(flags, 'due') ? { dueDate: strFlag(flags, 'due') } : {}),
|
|
590
|
+
...(strFlag(flags, 'completed') ? { completedAt: strFlag(flags, 'completed') } : {}),
|
|
591
|
+
...(strFlag(flags, 'return-url') ? { returnUrl: strFlag(flags, 'return-url') } : {}),
|
|
592
|
+
...(expiresInDays !== undefined ? { expiresInDays } : {}),
|
|
593
|
+
}, requireKey(ctx), { idempotencyKey: strFlag(flags, 'idempotency-key') });
|
|
594
|
+
ctx.err('NOTE: the confirmationToken below is shown ONCE — deliver it to the counterparty over your own channel.');
|
|
595
|
+
show(ctx, created);
|
|
596
|
+
return 0;
|
|
597
|
+
}
|
|
598
|
+
case 'list': {
|
|
599
|
+
const { flags } = parseFlags(rest, { valued: ['status', 'cursor', 'limit'] });
|
|
600
|
+
const status = strFlag(flags, 'status');
|
|
601
|
+
show(ctx, await ctx.client.listConfirmations(requireKey(ctx), {
|
|
602
|
+
...pageQuery(flags),
|
|
603
|
+
...(status ? { status: status.toUpperCase() } : {}),
|
|
604
|
+
}));
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
case 'get':
|
|
608
|
+
show(ctx, await ctx.client.getConfirmation(requireArg(rest, 'id'), requireKey(ctx)));
|
|
609
|
+
return 0;
|
|
610
|
+
case 'cancel':
|
|
611
|
+
show(ctx, await ctx.client.cancelConfirmation(requireArg(rest, 'id'), requireKey(ctx)));
|
|
612
|
+
return 0;
|
|
613
|
+
case 'preview': {
|
|
614
|
+
// KEYLESS on purpose — the counterparty holds a token, not a key.
|
|
615
|
+
const { positional, flags } = parseFlags(rest, { valued: ['token'] });
|
|
616
|
+
const id = requireArg(positional, 'id');
|
|
617
|
+
const token = strFlag(flags, 'token');
|
|
618
|
+
if (!token)
|
|
619
|
+
throw new Error('confirmations preview needs --token <t> (the one-time token you were sent)');
|
|
620
|
+
show(ctx, await ctx.client.previewConfirmation(id, token));
|
|
621
|
+
return 0;
|
|
622
|
+
}
|
|
623
|
+
case 'respond': {
|
|
624
|
+
// KEYLESS on purpose. The decision is explicit: there is no default,
|
|
625
|
+
// because confirming an outcome you did not witness is the one thing
|
|
626
|
+
// this primitive exists to prevent.
|
|
627
|
+
const { positional, flags } = parseFlags(rest, {
|
|
628
|
+
valued: ['token'],
|
|
629
|
+
boolean: ['confirm', 'decline'],
|
|
630
|
+
});
|
|
631
|
+
const id = requireArg(positional, 'id');
|
|
632
|
+
const token = strFlag(flags, 'token');
|
|
633
|
+
if (!token)
|
|
634
|
+
throw new Error('confirmations respond needs --token <t> (the one-time token you were sent)');
|
|
635
|
+
const confirm = flags.confirm === true;
|
|
636
|
+
const decline = flags.decline === true;
|
|
637
|
+
if (confirm === decline) {
|
|
638
|
+
throw new Error('confirmations respond needs exactly one of --confirm or --decline');
|
|
639
|
+
}
|
|
640
|
+
show(ctx, await ctx.client.respondToConfirmation(id, token, confirm ? 'confirm' : 'decline'));
|
|
641
|
+
return 0;
|
|
642
|
+
}
|
|
643
|
+
default:
|
|
644
|
+
throw new Error(`unknown confirmations subcommand "${sub ?? ''}" — see "credda help"`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
case 'policies': {
|
|
648
|
+
const [sub, ...rest] = args;
|
|
649
|
+
const key = requireKey(ctx);
|
|
650
|
+
switch (sub) {
|
|
651
|
+
case 'list': {
|
|
652
|
+
const { flags } = parseFlags(rest, { valued: ['cursor', 'limit'] });
|
|
653
|
+
show(ctx, await ctx.client.listPolicies(key, pageQuery(flags)));
|
|
654
|
+
return 0;
|
|
655
|
+
}
|
|
656
|
+
case 'get':
|
|
657
|
+
show(ctx, await ctx.client.getPolicy(requireArg(rest, 'id'), key));
|
|
658
|
+
return 0;
|
|
659
|
+
case 'create': {
|
|
660
|
+
const { flags } = parseFlags(rest, {
|
|
661
|
+
valued: ['name', 'user', 'metric', 'direction', 'threshold', 'component', 'band'],
|
|
662
|
+
boolean: ['all'],
|
|
663
|
+
});
|
|
664
|
+
const name = strFlag(flags, 'name');
|
|
665
|
+
const metric = strFlag(flags, 'metric');
|
|
666
|
+
if (!name || !metric) {
|
|
667
|
+
throw new Error('policies create needs --name <n> and --metric <score|component|band|verified_events>');
|
|
668
|
+
}
|
|
669
|
+
const userId = strFlag(flags, 'user');
|
|
670
|
+
const appliesToAll = flags.all === true;
|
|
671
|
+
if (Boolean(userId) === appliesToAll) {
|
|
672
|
+
throw new Error('policies create needs exactly one of --user <externalId> (watch one subject) or --all (watch all your subjects)');
|
|
673
|
+
}
|
|
674
|
+
const threshold = numFlag(flags, 'threshold');
|
|
675
|
+
const input = {
|
|
676
|
+
name,
|
|
677
|
+
metric: metric,
|
|
678
|
+
...(userId ? { userId } : { appliesToAll: true }),
|
|
679
|
+
};
|
|
680
|
+
const direction = strFlag(flags, 'direction');
|
|
681
|
+
if (direction)
|
|
682
|
+
input.direction = direction;
|
|
683
|
+
if (threshold !== undefined)
|
|
684
|
+
input.threshold = threshold;
|
|
685
|
+
const component = strFlag(flags, 'component');
|
|
686
|
+
if (component)
|
|
687
|
+
input.component = component;
|
|
688
|
+
const band = strFlag(flags, 'band');
|
|
689
|
+
if (band)
|
|
690
|
+
input.band = band;
|
|
691
|
+
show(ctx, await ctx.client.createPolicy(input, key));
|
|
692
|
+
return 0;
|
|
693
|
+
}
|
|
694
|
+
case 'update': {
|
|
695
|
+
const { positional, flags } = parseFlags(rest, {
|
|
696
|
+
valued: ['name', 'direction', 'threshold', 'component', 'band'],
|
|
697
|
+
boolean: ['activate', 'deactivate'],
|
|
698
|
+
});
|
|
699
|
+
const id = requireArg(positional, 'id');
|
|
700
|
+
if (flags.activate === true && flags.deactivate === true) {
|
|
701
|
+
throw new Error('policies update — pass at most one of --activate / --deactivate');
|
|
702
|
+
}
|
|
703
|
+
const patch = {};
|
|
704
|
+
const name = strFlag(flags, 'name');
|
|
705
|
+
if (name)
|
|
706
|
+
patch.name = name;
|
|
707
|
+
const direction = strFlag(flags, 'direction');
|
|
708
|
+
if (direction)
|
|
709
|
+
patch.direction = direction;
|
|
710
|
+
const threshold = numFlag(flags, 'threshold');
|
|
711
|
+
if (threshold !== undefined)
|
|
712
|
+
patch.threshold = threshold;
|
|
713
|
+
const component = strFlag(flags, 'component');
|
|
714
|
+
if (component)
|
|
715
|
+
patch.component = component;
|
|
716
|
+
const band = strFlag(flags, 'band');
|
|
717
|
+
if (band)
|
|
718
|
+
patch.band = band;
|
|
719
|
+
if (flags.activate === true)
|
|
720
|
+
patch.isActive = true;
|
|
721
|
+
if (flags.deactivate === true)
|
|
722
|
+
patch.isActive = false;
|
|
723
|
+
if (Object.keys(patch).length === 0) {
|
|
724
|
+
throw new Error('policies update needs at least one field to change — see "credda help"');
|
|
725
|
+
}
|
|
726
|
+
show(ctx, await ctx.client.updatePolicy(id, patch, key));
|
|
727
|
+
return 0;
|
|
728
|
+
}
|
|
729
|
+
case 'delete':
|
|
730
|
+
await ctx.client.deletePolicy(requireArg(rest, 'id'), key);
|
|
731
|
+
show(ctx, { deleted: true });
|
|
732
|
+
return 0;
|
|
733
|
+
default:
|
|
734
|
+
throw new Error(`unknown policies subcommand "${sub ?? ''}" — see "credda help"`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
case 'monitors': {
|
|
738
|
+
const [sub, ...rest] = args;
|
|
739
|
+
const key = requireKey(ctx);
|
|
740
|
+
switch (sub) {
|
|
741
|
+
case 'list': {
|
|
742
|
+
const { flags } = parseFlags(rest, { valued: ['cursor', 'limit'] });
|
|
743
|
+
show(ctx, await ctx.client.listMonitors(key, pageQuery(flags)));
|
|
744
|
+
return 0;
|
|
745
|
+
}
|
|
746
|
+
case 'get':
|
|
747
|
+
show(ctx, await ctx.client.getMonitor(requireArg(rest, 'id'), key));
|
|
748
|
+
return 0;
|
|
749
|
+
case 'create': {
|
|
750
|
+
const { flags } = parseFlags(rest, {
|
|
751
|
+
valued: ['user', 'below', 'above'],
|
|
752
|
+
boolean: ['band-change'],
|
|
753
|
+
});
|
|
754
|
+
const userId = strFlag(flags, 'user');
|
|
755
|
+
if (!userId) {
|
|
756
|
+
throw new Error('monitors create needs --user <externalId> (the user to watch)');
|
|
757
|
+
}
|
|
758
|
+
const belowScore = numFlag(flags, 'below');
|
|
759
|
+
const aboveScore = numFlag(flags, 'above');
|
|
760
|
+
const onBandChange = flags['band-change'] === true;
|
|
761
|
+
if (belowScore === undefined && aboveScore === undefined && !onBandChange) {
|
|
762
|
+
throw new Error('monitors create needs at least one condition: --below <score> (fires when the score crosses DOWN through it), --above <score> (crosses UP), or --band-change (any band change)');
|
|
763
|
+
}
|
|
764
|
+
show(ctx, await ctx.client.createMonitor({
|
|
765
|
+
userId,
|
|
766
|
+
...(belowScore !== undefined ? { belowScore } : {}),
|
|
767
|
+
...(aboveScore !== undefined ? { aboveScore } : {}),
|
|
768
|
+
...(onBandChange ? { onBandChange } : {}),
|
|
769
|
+
}, key));
|
|
770
|
+
return 0;
|
|
771
|
+
}
|
|
772
|
+
case 'delete':
|
|
773
|
+
await ctx.client.deleteMonitor(requireArg(rest, 'id'), key);
|
|
774
|
+
show(ctx, { deleted: true });
|
|
775
|
+
return 0;
|
|
776
|
+
default:
|
|
777
|
+
throw new Error(`unknown monitors subcommand "${sub ?? ''}" — see "credda help"`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
case 'screen': {
|
|
781
|
+
const { positional, flags } = parseFlags(args, {
|
|
782
|
+
valued: ['file'],
|
|
783
|
+
boolean: ['wait'],
|
|
784
|
+
});
|
|
785
|
+
const file = strFlag(flags, 'file');
|
|
786
|
+
if (file && positional.length > 0) {
|
|
787
|
+
throw new Error('screen — pass ids inline OR --file <path>, not both');
|
|
788
|
+
}
|
|
789
|
+
const ids = parseIdList(file ? { fileText: await ctx.readInput(file) } : { inline: positional });
|
|
790
|
+
if (ids.length === 0) {
|
|
791
|
+
throw new Error('screen — no ids found. Pass ids (comma/space separated) or --file <path> (one id per line, or a CSV whose first column is the id)');
|
|
792
|
+
}
|
|
793
|
+
const key = requireKey(ctx);
|
|
794
|
+
let job = (await ctx.client.createScreening(ids, key)).screening;
|
|
795
|
+
if (flags.wait === true) {
|
|
796
|
+
const sleep = ctx.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
797
|
+
const interval = ctx.pollIntervalMs ?? 2000;
|
|
798
|
+
while (job.status === 'QUEUED' || job.status === 'RUNNING') {
|
|
799
|
+
ctx.err(`screening ${job.id}: ${job.status} — waiting…`);
|
|
800
|
+
await sleep(interval);
|
|
801
|
+
job = (await ctx.client.getScreening(job.id, key)).screening;
|
|
802
|
+
}
|
|
803
|
+
show(ctx, { screening: job });
|
|
804
|
+
return job.status === 'COMPLETED' ? 0 : 1;
|
|
805
|
+
}
|
|
806
|
+
show(ctx, { screening: job });
|
|
807
|
+
if (job.status === 'QUEUED' || job.status === 'RUNNING') {
|
|
808
|
+
ctx.err(`still ${job.status} — poll with "credda screenings get ${job.id}" (or re-run with --wait)`);
|
|
809
|
+
}
|
|
152
810
|
return 0;
|
|
153
811
|
}
|
|
812
|
+
case 'screenings': {
|
|
813
|
+
const [sub, ...rest] = args;
|
|
814
|
+
const key = requireKey(ctx);
|
|
815
|
+
switch (sub) {
|
|
816
|
+
case 'list': {
|
|
817
|
+
const { flags } = parseFlags(rest, { valued: ['cursor', 'limit'] });
|
|
818
|
+
show(ctx, await ctx.client.listScreenings(key, pageQuery(flags)));
|
|
819
|
+
return 0;
|
|
820
|
+
}
|
|
821
|
+
case 'get':
|
|
822
|
+
show(ctx, await ctx.client.getScreening(requireArg(rest, 'id'), key));
|
|
823
|
+
return 0;
|
|
824
|
+
case 'results': {
|
|
825
|
+
const { positional, flags } = parseFlags(rest, { valued: ['csv'] });
|
|
826
|
+
const id = requireArg(positional, 'id');
|
|
827
|
+
const outfile = strFlag(flags, 'csv');
|
|
828
|
+
if (outfile) {
|
|
829
|
+
const { fetchCsv, writeFile } = requireCsvIo(ctx);
|
|
830
|
+
await writeFile(outfile, await fetchCsv(`/screenings/${encodeURIComponent(id)}/results?format=csv`, key));
|
|
831
|
+
show(ctx, { written: outfile });
|
|
832
|
+
return 0;
|
|
833
|
+
}
|
|
834
|
+
show(ctx, await ctx.client.getScreeningResults(id, key));
|
|
835
|
+
return 0;
|
|
836
|
+
}
|
|
837
|
+
default:
|
|
838
|
+
throw new Error(`unknown screenings subcommand "${sub ?? ''}" — see "credda help"`);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
154
841
|
case 'webhooks': {
|
|
155
842
|
const [sub, ...rest] = args;
|
|
156
843
|
const key = requireKey(ctx);
|
|
@@ -162,7 +849,7 @@ export async function runCli(argv, ctx) {
|
|
|
162
849
|
const url = requireArg(rest, 'url');
|
|
163
850
|
const events = rest.slice(1);
|
|
164
851
|
if (events.length === 0) {
|
|
165
|
-
throw new Error('webhooks create <url> <event...> — provide at least one event (score.updated, score.band_changed, dispute.resolved)');
|
|
852
|
+
throw new Error('webhooks create <url> <event...> — provide at least one event (score.updated, score.band_changed, dispute.resolved, monitor.triggered, usage.quota_warning)');
|
|
166
853
|
}
|
|
167
854
|
const created = await ctx.client.createWebhook({ url, events: events }, key);
|
|
168
855
|
ctx.err('NOTE: the signing secret below is shown ONCE — store it now.');
|
|
@@ -179,6 +866,13 @@ export async function runCli(argv, ctx) {
|
|
|
179
866
|
case 'deliveries':
|
|
180
867
|
show(ctx, await ctx.client.getWebhookDeliveries(requireArg(rest, 'id'), key, 25));
|
|
181
868
|
return 0;
|
|
869
|
+
case 'recent': {
|
|
870
|
+
// Sample data across ALL endpoints — falls back to the event
|
|
871
|
+
// catalog's examples (isExample:true) when nothing has fired yet.
|
|
872
|
+
const eventType = rest.length > 0 ? rest : undefined;
|
|
873
|
+
show(ctx, await ctx.client.getRecentWebhookEvents(key, { limit: 25, eventType }));
|
|
874
|
+
return 0;
|
|
875
|
+
}
|
|
182
876
|
default:
|
|
183
877
|
throw new Error(`unknown webhooks subcommand "${sub ?? ''}" — see "credda help"`);
|
|
184
878
|
}
|
|
@@ -208,6 +902,8 @@ export async function runCli(argv, ctx) {
|
|
|
208
902
|
const message = e instanceof Error ? e.message : String(e);
|
|
209
903
|
// SDK errors already carry a "credda:" prefix — don't double it.
|
|
210
904
|
ctx.err(message.startsWith('credda:') ? message : `credda: ${message}`);
|
|
905
|
+
for (const line of errorHints(e))
|
|
906
|
+
ctx.err(line);
|
|
211
907
|
return 1;
|
|
212
908
|
}
|
|
213
909
|
}
|