apiblaze 0.16.0 → 0.17.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.
Files changed (2) hide show
  1. package/dist/index.js +160 -125
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -460,6 +460,7 @@ var init_admin = __esm({
460
460
  // src/lib/tenant-create.ts
461
461
  var tenant_create_exports = {};
462
462
  __export(tenant_create_exports, {
463
+ addApiblazeHostedLogin: () => addApiblazeHostedLogin,
463
464
  createTenantInteractive: () => createTenantInteractive
464
465
  });
465
466
  async function createTenantRow(teamId) {
@@ -490,23 +491,22 @@ async function createTenantRow(teamId) {
490
491
  }
491
492
  }
492
493
  }
493
- async function setUpLogin(teamId, tenant2) {
494
+ async function addApiblazeHostedLogin(teamId, tenant2, opts = {}) {
494
495
  const { default: inquirer2 } = await import("inquirer");
495
496
  const base = `/teams/${encodeURIComponent(teamId)}/tenants/${encodeURIComponent(tenant2)}`;
496
497
  const { provider } = await inquirer2.prompt([{
497
498
  type: "list",
498
499
  name: "provider",
499
- message: "How do people log in?",
500
+ message: "Which login provider?",
500
501
  pageSize: 10,
501
502
  choices: [
502
503
  ...PROVIDERS.map((p) => ({ name: p.label, value: p.id })),
503
- new inquirer2.Separator(),
504
- { name: import_chalk22.default.dim("Skip for now \u2014 set up a login method later"), value: null }
504
+ ...opts.allowSkip ? [new inquirer2.Separator(), { name: import_chalk22.default.dim("Skip for now \u2014 set up a login method later"), value: null }] : []
505
505
  ]
506
506
  }]);
507
507
  if (!provider) {
508
508
  console.log(import_chalk22.default.dim(" No login method yet \u2014 add one later under Login methods."));
509
- return;
509
+ return false;
510
510
  }
511
511
  const opt = PROVIDERS.find((p) => p.id === provider);
512
512
  let providerBody;
@@ -542,8 +542,9 @@ async function setUpLogin(teamId, tenant2) {
542
542
  const clientId = client?.clientId ?? client?.client_id;
543
543
  await admin({ method: "POST", path: `${base}/app-clients/${encodeURIComponent(clientId)}/providers`, body: providerBody, summary: `Add ${provider} provider` });
544
544
  spinner.succeed(opt.own ? `${opt.label} login is ready.` : "APIblaze-hosted GitHub login is ready.");
545
+ return true;
545
546
  } catch (err) {
546
- spinner.fail("Login setup failed (the tenant was still created).");
547
+ spinner.fail("Login setup failed.");
547
548
  throw err;
548
549
  }
549
550
  }
@@ -562,7 +563,7 @@ async function createTenantInteractive(teamId) {
562
563
  });
563
564
  console.log(import_chalk22.default.green(" Users & groups enabled."));
564
565
  }
565
- await setUpLogin(teamId, tenant2);
566
+ await addApiblazeHostedLogin(teamId, tenant2, { allowSkip: true });
566
567
  return tenant2;
567
568
  }
568
569
  var import_chalk22, import_ora8, PROVIDERS, DEFAULT_SCOPES;
@@ -686,7 +687,7 @@ var import_commander = require("commander");
686
687
  var import_chalk35 = __toESM(require("chalk"));
687
688
 
688
689
  // package.json
689
- var version = "0.16.0";
690
+ var version = "0.17.1";
690
691
 
691
692
  // src/index.ts
692
693
  init_types();
@@ -3065,6 +3066,8 @@ Tenant ${tenant2}`));
3065
3066
  const nEmails = (emails?.admin_emails ?? []).length;
3066
3067
  const nIssuers = (issuers?.external_issuers ?? []).length;
3067
3068
  const nClients = Array.isArray(clients) ? clients.length : 0;
3069
+ const nOpaque = opaque?.opaque?.endpoint ? 1 : 0;
3070
+ const nLogin = nClients + nIssuers + nOpaque;
3068
3071
  const onOff = (b) => b ? import_chalk25.default.green("on") : import_chalk25.default.dim("off");
3069
3072
  const { pick: pick2 } = await inquirer2.prompt([{
3070
3073
  type: "list",
@@ -3072,21 +3075,19 @@ Tenant ${tenant2}`));
3072
3075
  message: `Tenant ${tenant2}:`,
3073
3076
  pageSize: 12,
3074
3077
  choices: [
3075
- { name: `Login methods (${nClients}) ${import_chalk25.default.dim("how your consumers sign in \u2014 providers live inside")}`, value: "clients" },
3078
+ { name: `Login methods (${nLogin}) ${import_chalk25.default.dim("how your consumers sign in")}`, value: "login" },
3076
3079
  { name: `Users & groups: ${onOff(iam?.iam_enabled)} ${import_chalk25.default.dim(`identity & key management (${tenant2}.iam.apiblaze.com)`)}`, value: "iam" },
3077
- { name: `Your own hosted login \u2014 JWT (${nIssuers}) ${import_chalk25.default.dim("trust tokens your own identity provider issues")}`, value: "issuers" },
3078
3080
  { name: `Portal admins (${nEmails}) ${import_chalk25.default.dim("emails allowed to administer this tenant's portal")}`, value: "emails" },
3079
3081
  new inquirer2.Separator(import_chalk25.default.dim(" Settings")),
3080
3082
  { name: `CORS: ${cors?.cors ? import_chalk25.default.cyan(JSON.stringify(cors.cors)) : import_chalk25.default.dim("(unset)")} ${import_chalk25.default.dim("browser origins allowed to call this tenant")}`, value: "cors" },
3081
- { name: `Opaque-token login (advanced): ${opaque?.opaque?.endpoint ? import_chalk25.default.cyan(opaque.opaque.endpoint) : import_chalk25.default.dim("(unset)")}`, value: "opaque" },
3082
3083
  { name: "\u2190 Back", value: "back" }
3083
3084
  ]
3084
3085
  }]);
3085
3086
  switch (pick2) {
3086
3087
  case "back":
3087
3088
  return;
3088
- case "clients":
3089
- await clientsMenu(teamId, tenant2, base);
3089
+ case "login":
3090
+ await loginMethodsMenu(teamId, tenant2, base);
3090
3091
  break;
3091
3092
  case "iam": {
3092
3093
  const { v } = await inquirer2.prompt([{ type: "confirm", name: "v", message: "Enable Users & groups?", default: !!iam?.iam_enabled }]);
@@ -3114,38 +3115,88 @@ Tenant ${tenant2}`));
3114
3115
  case "emails":
3115
3116
  await emailsMenu(base, emails?.admin_emails ?? []);
3116
3117
  break;
3117
- case "issuers":
3118
- await issuersMenu(base, issuers?.external_issuers ?? []);
3119
- break;
3120
- case "opaque": {
3121
- const cur = opaque?.opaque;
3122
- const { mode } = await inquirer2.prompt([{
3123
- type: "list",
3124
- name: "mode",
3125
- message: "Opaque-token validator:",
3126
- choices: [
3127
- { name: cur ? "Replace it" : "Set one up", value: "set" },
3128
- ...cur ? [{ name: "Clear it", value: "clear" }] : [],
3129
- { name: "\u2190 Back", value: "back" }
3130
- ]
3131
- }]);
3132
- if (mode === "back") break;
3133
- if (mode === "clear") {
3134
- await admin({ method: "PUT", path: `${base}/opaque`, body: { opaque: null }, summary: "Clear opaque validator" });
3135
- console.log(import_chalk25.default.green(" Cleared."));
3136
- break;
3137
- }
3138
- const a = await inquirer2.prompt([
3139
- { type: "input", name: "endpoint", message: "Introspection endpoint (https):", default: cur?.endpoint, validate: (s) => s.startsWith("https://") || "must be https" },
3140
- { type: "list", name: "method", message: "HTTP method:", choices: ["GET", "POST"], default: cur?.method ?? "GET" }
3141
- ]);
3142
- await admin({ method: "PUT", path: `${base}/opaque`, body: { opaque: { endpoint: a.endpoint, method: a.method } }, summary: "Set opaque validator" });
3143
- console.log(import_chalk25.default.green(" Opaque validator set."));
3144
- break;
3145
- }
3146
3118
  }
3147
3119
  }
3148
3120
  }
3121
+ async function loginMethodsMenu(teamId, tenant2, base) {
3122
+ const { default: inquirer2 } = await import("inquirer");
3123
+ for (; ; ) {
3124
+ const spinner = (0, import_ora11.default)("Loading login methods...").start();
3125
+ const [rawClients, rawIssuers, rawOpaque] = await Promise.all([
3126
+ admin({ method: "GET", path: `${base}/app-clients`, summary: "List APIblaze-hosted logins" }).catch(() => []),
3127
+ admin({ method: "GET", path: `${base}/external-issuers`, summary: "List your-own-JWT logins" }).catch(() => null),
3128
+ admin({ method: "GET", path: `${base}/opaque`, summary: "Read opaque login" }).catch(() => null)
3129
+ ]).finally(() => spinner.stop());
3130
+ const appClients = Array.isArray(rawClients) ? rawClients : [];
3131
+ const issuers = rawIssuers?.external_issuers ?? [];
3132
+ const opaque = rawOpaque?.opaque ?? null;
3133
+ const choices = [];
3134
+ for (const c of appClients) {
3135
+ const nP = (c.providers ?? []).length || c.providers_count || 0;
3136
+ choices.push({ name: `${import_chalk25.default.cyan("APIblaze-hosted")} ${import_chalk25.default.bold(c.name ?? c.clientId)} ${import_chalk25.default.dim(`${c.clientId}${nP ? ` \xB7 ${nP} provider${nP === 1 ? "" : "s"}` : import_chalk25.default.yellow(" \xB7 needs a provider")}`)}`, value: { kind: "client", item: c } });
3137
+ }
3138
+ for (const i of issuers) {
3139
+ choices.push({ name: `${import_chalk25.default.cyan("Your own \u2014 JWT ")} ${import_chalk25.default.bold(i.iss)} ${import_chalk25.default.dim(`aud=${i.aud}`)}`, value: { kind: "issuer", item: i } });
3140
+ }
3141
+ if (opaque?.endpoint) {
3142
+ choices.push({ name: `${import_chalk25.default.cyan("Your own \u2014 opaque")} ${import_chalk25.default.bold(opaque.endpoint)}`, value: { kind: "opaque", item: opaque } });
3143
+ }
3144
+ if (!choices.length) console.log(import_chalk25.default.dim("\n No login methods yet \u2014 consumers cannot sign in until you add one."));
3145
+ const { pick: pick2 } = await inquirer2.prompt([{
3146
+ type: "list",
3147
+ name: "pick",
3148
+ message: `Login methods for ${tenant2}:`,
3149
+ pageSize: 15,
3150
+ choices: [
3151
+ ...choices,
3152
+ new inquirer2.Separator(),
3153
+ { name: "\uFF0B Add a login method\u2026", value: { kind: "add" } },
3154
+ { name: "\u2190 Back", value: { kind: "back" } }
3155
+ ]
3156
+ }]);
3157
+ if (pick2.kind === "back") return;
3158
+ if (pick2.kind === "add") {
3159
+ await addLoginMethod(teamId, tenant2, base);
3160
+ continue;
3161
+ }
3162
+ if (pick2.kind === "client") {
3163
+ await clientHome(base, pick2.item);
3164
+ continue;
3165
+ }
3166
+ if (pick2.kind === "issuer") {
3167
+ await issuerHome(base, pick2.item);
3168
+ continue;
3169
+ }
3170
+ if (pick2.kind === "opaque") {
3171
+ await opaqueHome(base, pick2.item);
3172
+ continue;
3173
+ }
3174
+ }
3175
+ }
3176
+ async function addLoginMethod(teamId, tenant2, base) {
3177
+ const { default: inquirer2 } = await import("inquirer");
3178
+ const { kind } = await inquirer2.prompt([{
3179
+ type: "list",
3180
+ name: "kind",
3181
+ message: "How do people log in?",
3182
+ pageSize: 8,
3183
+ choices: [
3184
+ { name: `APIblaze hosted login page ${import_chalk25.default.dim("we host login; pick a provider (GitHub/Google/\u2026) \u2014 easiest")}`, value: "apiblaze" },
3185
+ { name: `Your own hosted login \u2014 JWT ${import_chalk25.default.dim("your identity provider issues JWTs; we trust them")}`, value: "jwt" },
3186
+ { name: `Your own login \u2014 opaque token ${import_chalk25.default.dim("we validate your opaque tokens via an introspection endpoint")}`, value: "opaque" },
3187
+ { name: "\u2190 Back", value: null }
3188
+ ]
3189
+ }]);
3190
+ if (!kind) return;
3191
+ if (kind === "apiblaze") {
3192
+ const { addApiblazeHostedLogin: addApiblazeHostedLogin2 } = await Promise.resolve().then(() => (init_tenant_create(), tenant_create_exports));
3193
+ await addApiblazeHostedLogin2(teamId, tenant2);
3194
+ } else if (kind === "jwt") {
3195
+ await addIssuer(base);
3196
+ } else {
3197
+ await setOpaque(base, null);
3198
+ }
3199
+ }
3149
3200
  function safeJson(s) {
3150
3201
  try {
3151
3202
  return JSON.parse(s);
@@ -3185,98 +3236,84 @@ async function emailsMenu(base, emails) {
3185
3236
  console.log(import_chalk25.default.green(` ${e} removed.`));
3186
3237
  }
3187
3238
  }
3188
- async function issuersMenu(base, issuers) {
3239
+ async function addIssuer(base) {
3189
3240
  const { default: inquirer2 } = await import("inquirer");
3190
- console.log();
3191
- if (!issuers.length) console.log(import_chalk25.default.dim(" No external issuers \u2014 consumers use APIblaze-issued tokens."));
3192
- for (const i of issuers) console.log(` ${import_chalk25.default.bold(i.iss)} aud=${i.aud} ${import_chalk25.default.dim(i.sub_semantics ?? "")}`);
3241
+ const a = await inquirer2.prompt([
3242
+ { type: "input", name: "iss", message: "Issuer URL (iss):", validate: (s) => !!s.trim() || "required" },
3243
+ { type: "input", name: "aud", message: "Audience (aud):", validate: (s) => !!s.trim() || "required" },
3244
+ { type: "input", name: "jwks", message: "JWKS URL (empty = derive from issuer):" },
3245
+ { type: "list", name: "sem", message: "Where is the end-user id?", choices: [
3246
+ { name: "The token sub IS the end user (tenant-owned)", value: "tenant_owned" },
3247
+ { name: "Extract it from a claim\u2026", value: "extract_from_claim" }
3248
+ ] }
3249
+ ]);
3250
+ const claim = a.sem === "extract_from_claim" ? (await inquirer2.prompt([{ type: "input", name: "c", message: "Claim name:", validate: (s) => !!s.trim() || "required" }])).c : void 0;
3251
+ await admin({
3252
+ method: "POST",
3253
+ path: `${base}/external-issuers`,
3254
+ body: { iss: a.iss.trim(), aud: a.aud.trim(), jwks_url: a.jwks.trim() || null, sub_semantics: a.sem, ...claim ? { claim_name: claim } : {} },
3255
+ summary: `Add external issuer ${a.iss.trim()}`
3256
+ });
3257
+ console.log(import_chalk25.default.green(" JWT login method saved."));
3258
+ }
3259
+ async function issuerHome(base, issuer) {
3260
+ const { default: inquirer2 } = await import("inquirer");
3261
+ console.log(`
3262
+ ${import_chalk25.default.bold(issuer.iss)} ${import_chalk25.default.dim(`aud=${issuer.aud} \xB7 ${issuer.sub_semantics ?? ""}`)}`);
3193
3263
  const { act } = await inquirer2.prompt([{
3194
3264
  type: "list",
3195
3265
  name: "act",
3196
- message: "External JWT issuers:",
3266
+ message: "This JWT login method:",
3197
3267
  choices: [
3198
- { name: "Add / replace one issuer", value: "add" },
3199
- ...issuers.length ? [{ name: "Delete an issuer", value: "rm" }] : [],
3268
+ { name: "Replace it (re-enter details)", value: "edit" },
3269
+ { name: import_chalk25.default.red("Delete it"), value: "rm" },
3200
3270
  { name: "\u2190 Back", value: "back" }
3201
3271
  ]
3202
3272
  }]);
3203
3273
  if (act === "back") return;
3204
- if (act === "add") {
3205
- const a = await inquirer2.prompt([
3206
- { type: "input", name: "iss", message: "Issuer URL (iss):", validate: (s) => !!s.trim() || "required" },
3207
- { type: "input", name: "aud", message: "Audience (aud):", validate: (s) => !!s.trim() || "required" },
3208
- { type: "input", name: "jwks", message: "JWKS URL (empty = derive from issuer):" },
3209
- { type: "list", name: "sem", message: "Where is the end-user id?", choices: [
3210
- { name: "The token sub IS the end user (tenant-owned)", value: "tenant_owned" },
3211
- { name: "Extract it from a claim\u2026", value: "extract_from_claim" }
3212
- ] }
3213
- ]);
3214
- const claim = a.sem === "extract_from_claim" ? (await inquirer2.prompt([{ type: "input", name: "c", message: "Claim name:", validate: (s) => !!s.trim() || "required" }])).c : void 0;
3215
- await admin({
3216
- method: "POST",
3217
- path: `${base}/external-issuers`,
3218
- body: { iss: a.iss.trim(), aud: a.aud.trim(), jwks_url: a.jwks.trim() || null, sub_semantics: a.sem, ...claim ? { claim_name: claim } : {} },
3219
- summary: `Add external issuer ${a.iss.trim()}`
3220
- });
3221
- console.log(import_chalk25.default.green(" Issuer saved."));
3222
- } else {
3223
- const { i } = await inquirer2.prompt([{
3224
- type: "list",
3225
- name: "i",
3226
- message: "Delete which issuer?",
3227
- choices: [...issuers.map((x) => ({ name: `${x.iss} (aud=${x.aud})`, value: x })), { name: "\u2190 Back", value: null }]
3228
- }]);
3229
- if (!i) return;
3230
- await admin({
3231
- method: "DELETE",
3232
- path: `${base}/external-issuers?iss=${encodeURIComponent(i.iss)}&aud=${encodeURIComponent(i.aud)}`,
3233
- summary: `Delete issuer ${i.iss}`
3234
- });
3235
- console.log(import_chalk25.default.green(" Issuer deleted."));
3274
+ if (act === "edit") {
3275
+ await addIssuer(base);
3276
+ return;
3236
3277
  }
3278
+ const { sure } = await inquirer2.prompt([{ type: "confirm", name: "sure", message: `Delete the JWT login method for ${issuer.iss}? Consumers using it can no longer sign in.`, default: false }]);
3279
+ if (!sure) return;
3280
+ await admin({
3281
+ method: "DELETE",
3282
+ path: `${base}/external-issuers?iss=${encodeURIComponent(issuer.iss)}&aud=${encodeURIComponent(issuer.aud)}`,
3283
+ summary: `Delete issuer ${issuer.iss}`
3284
+ });
3285
+ console.log(import_chalk25.default.green(" Deleted."));
3237
3286
  }
3238
- async function clientsMenu(teamId, tenant2, base) {
3287
+ async function setOpaque(base, cur) {
3239
3288
  const { default: inquirer2 } = await import("inquirer");
3240
- for (; ; ) {
3241
- const spinner = (0, import_ora11.default)("Loading app clients...").start();
3242
- const raw = await admin({ method: "GET", path: `${base}/app-clients`, summary: "List app clients" }).catch(() => []);
3243
- spinner.stop();
3244
- const clients = Array.isArray(raw) ? raw : [];
3245
- const { pick: pick2 } = await inquirer2.prompt([{
3246
- type: "list",
3247
- name: "pick",
3248
- message: `Login methods for ${tenant2}:`,
3249
- pageSize: 15,
3250
- choices: [
3251
- ...clients.map((c) => ({
3252
- name: `${import_chalk25.default.bold(c.name ?? c.clientId)} ${import_chalk25.default.dim(`${c.clientId}${c.projectName ? ` \xB7 ${c.projectName}` : ""}`)}`,
3253
- value: c
3254
- })),
3255
- ...clients.length ? [] : [new inquirer2.Separator(import_chalk25.default.dim(" no app clients yet"))],
3256
- { name: "\uFF0B Add a login method\u2026", value: " create" },
3257
- { name: "\u2190 Back", value: " back" }
3258
- ]
3259
- }]);
3260
- if (pick2 === " back") return;
3261
- if (pick2 === " create") {
3262
- const a = await inquirer2.prompt([
3263
- { type: "input", name: "name", message: "Client name:", validate: (s) => !!s.trim() || "required" },
3264
- { type: "input", name: "callbacks", message: "Callback URLs (comma-separated, empty = none):" }
3265
- ]);
3266
- const created = await admin({
3267
- method: "POST",
3268
- path: `${base}/app-clients`,
3269
- body: {
3270
- name: a.name.trim(),
3271
- ...a.callbacks.trim() ? { authorizedCallbackUrls: parseList(a.callbacks) } : {}
3272
- },
3273
- summary: `Create app client "${a.name.trim()}"`
3274
- });
3275
- console.log(import_chalk25.default.green(` Login method created${created?.clientId ? ` (${created.clientId})` : ""}.`));
3276
- continue;
3277
- }
3278
- await clientHome(base, pick2);
3289
+ const a = await inquirer2.prompt([
3290
+ { type: "input", name: "endpoint", message: "Introspection endpoint (https):", default: cur?.endpoint, validate: (s) => s.startsWith("https://") || "must be https" },
3291
+ { type: "list", name: "method", message: "HTTP method:", choices: ["GET", "POST"], default: cur?.method ?? "GET" }
3292
+ ]);
3293
+ await admin({ method: "PUT", path: `${base}/opaque`, body: { opaque: { endpoint: a.endpoint, method: a.method } }, summary: "Set opaque validator" });
3294
+ console.log(import_chalk25.default.green(" Opaque login method set."));
3295
+ }
3296
+ async function opaqueHome(base, cur) {
3297
+ const { default: inquirer2 } = await import("inquirer");
3298
+ console.log(`
3299
+ ${import_chalk25.default.bold(cur.endpoint)} ${import_chalk25.default.dim(cur.method ?? "GET")}`);
3300
+ const { act } = await inquirer2.prompt([{
3301
+ type: "list",
3302
+ name: "act",
3303
+ message: "This opaque login method:",
3304
+ choices: [
3305
+ { name: "Replace it", value: "edit" },
3306
+ { name: import_chalk25.default.red("Delete it"), value: "rm" },
3307
+ { name: "\u2190 Back", value: "back" }
3308
+ ]
3309
+ }]);
3310
+ if (act === "back") return;
3311
+ if (act === "edit") {
3312
+ await setOpaque(base, cur);
3313
+ return;
3279
3314
  }
3315
+ await admin({ method: "PUT", path: `${base}/opaque`, body: { opaque: null }, summary: "Clear opaque validator" });
3316
+ console.log(import_chalk25.default.green(" Deleted."));
3280
3317
  }
3281
3318
  async function clientHome(base, summary) {
3282
3319
  const { default: inquirer2 } = await import("inquirer");
@@ -4357,15 +4394,13 @@ async function transformsMenu(proj2) {
4357
4394
  { type: "input", name: "destination", message: `Destination field ${fieldHint}:`, validate: (s) => !!s || "required" },
4358
4395
  { type: "confirm", name: "strip", message: "Remove the source field after copying?", default: false }
4359
4396
  ]);
4360
- const source_fns = await buildFns("Transform the value as it is READ (source functions)");
4361
- const dest_fns = await buildFns("Transform the value as it is WRITTEN (destination functions)");
4397
+ const source_fns = await buildFns("Transform the value");
4362
4398
  action2 = {
4363
4399
  type: "copy",
4364
4400
  source: a.source,
4365
4401
  destination: a.destination,
4366
4402
  ...a.strip ? { strip_source: true } : {},
4367
- ...source_fns.length ? { source_fns } : {},
4368
- ...dest_fns.length ? { dest_fns } : {}
4403
+ ...source_fns.length ? { source_fns } : {}
4369
4404
  };
4370
4405
  }
4371
4406
  const condition = await buildCondition(ans.phase);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apiblaze",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "APIblaze CLI + sidecar — manage API proxies, run dev tunnels, and route a Next.js app's egress through APIblaze with one command",
5
5
  "keywords": [
6
6
  "apiblaze",