@carthooks/arcubase-cli 0.1.9 → 0.1.10

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.
@@ -6658,6 +6658,22 @@ function renderCommandHelp(scope, moduleName, commandName) {
6658
6658
  " - row create: arcubase row create --app-id <app_id> --table-id <table_id> --body-file insert.json",
6659
6659
  " - row query: arcubase row query --app-id <app_id> --table-id <table_id> --body-file query.json"
6660
6660
  ] : [],
6661
+ ...scope === "admin" && command.commandPath[0] === "access-rule" && command.commandPath[1] === "update" ? [
6662
+ "body-json examples:",
6663
+ ' - enable: {"update":["enabled"],"enabled":true}',
6664
+ ' - rename: {"update":["name"],"name":"Sales read only"}',
6665
+ ' - update options: {"update":["options"],"options":{"user_scope":[{"type":"member","id":123}]}}'
6666
+ ] : [],
6667
+ ...scope === "admin" && command.commandPath[0] === "access-rule" && command.commandPath[1] === "assign-users" ? [
6668
+ "body-json example:",
6669
+ ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
6670
+ "success requires:",
6671
+ " - result.enabled must be true",
6672
+ " - result.options.user_scope must contain the assigned numeric TenantUser.ID values",
6673
+ " - PermitUserScope.id must be numeric TenantUser.ID",
6674
+ " - Do not copy hash arcubaseTenantUserId into PermitUserScope.id",
6675
+ " - If result.enabled is false, enable the rule first and retry assign-users"
6676
+ ] : [],
6661
6677
  ...docHints.length > 0 ? ["docs:", ...docHints.map((item) => ` - ${item.title}: ${item.file}`)] : []
6662
6678
  ].join("\n");
6663
6679
  }
@@ -6836,7 +6852,38 @@ async function validateFileFieldPayloads(scope, command, runtimeEnv, flags, body
6836
6852
  }
6837
6853
  }
6838
6854
  }
6855
+ function isAssignUsersCommand(scope, command) {
6856
+ return scope === "admin" && command.commandPath[0] === "access-rule" && command.commandPath[1] === "assign-users";
6857
+ }
6858
+ function buildAssignUsersValidationDetails(scope, command, requestType, path2, message) {
6859
+ return {
6860
+ operation: `${scope}.${command.module}.${command.functionName}`,
6861
+ requestType,
6862
+ issues: [{ path: path2, message }],
6863
+ tsHints: [
6864
+ { symbol: "AppIngressUpdateReqVO", file: "/opt/arcubase-sdk/types/ingress.ts" },
6865
+ { symbol: "EntityIngressOptions", file: "/opt/arcubase-sdk/types/ingress.ts" },
6866
+ { symbol: "PermitUserScope", file: "/opt/arcubase-sdk/types/common.ts" }
6867
+ ],
6868
+ docHints: getDocHints(requestType),
6869
+ shapeHint: {
6870
+ update: ["user_scope"],
6871
+ options: {
6872
+ user_scope: [{ type: "member", id: 123 }]
6873
+ }
6874
+ },
6875
+ commonMistakes: [
6876
+ "do not use user_scope at top level",
6877
+ "do not use users or allowedUsers",
6878
+ "do not use hash tenant user id as id",
6879
+ "id must be numeric TenantUser.ID"
6880
+ ]
6881
+ };
6882
+ }
6839
6883
  function throwBodyValidationFailure(message, requestType, path2, scope, command) {
6884
+ if (isAssignUsersCommand(scope, command)) {
6885
+ throw new CLIError("BODY_VALIDATION_FAILED", message, 2, buildAssignUsersValidationDetails(scope, command, requestType, path2, message));
6886
+ }
6840
6887
  throw new CLIError("BODY_VALIDATION_FAILED", message, 2, {
6841
6888
  operation: `${scope}.${command.module}.${command.functionName}`,
6842
6889
  requestType,
@@ -6850,10 +6897,109 @@ function throwBodyValidationFailure(message, requestType, path2, scope, command)
6850
6897
  docHints: getDocHints(requestType)
6851
6898
  });
6852
6899
  }
6900
+ function stringArray(value) {
6901
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
6902
+ }
6903
+ function requireUpdateContains(scope, command, body, field) {
6904
+ if (!(field in body)) return;
6905
+ const update = stringArray(body.update);
6906
+ if (!update.includes(field)) {
6907
+ throwBodyValidationFailure(
6908
+ `body.update must include "${field}" when body.${field} is present. Example: {"update":["${field}"],"${field}":${field === "enabled" ? "true" : '"value"'}}`,
6909
+ command.requestType ?? "AppIngressUpdateReqVO",
6910
+ "body.update",
6911
+ scope,
6912
+ command
6913
+ );
6914
+ }
6915
+ }
6916
+ function validateActionSpecificRawBody(scope, command, body) {
6917
+ if (!isRecord2(body) || !command.requestType) {
6918
+ return;
6919
+ }
6920
+ if (isAssignUsersCommand(scope, command)) {
6921
+ if ("users" in body || "allowedUsers" in body || "user_scope" in body) {
6922
+ throwBodyValidationFailure(
6923
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
6924
+ command.requestType,
6925
+ "body.options.user_scope",
6926
+ scope,
6927
+ command
6928
+ );
6929
+ }
6930
+ }
6931
+ }
6853
6932
  function validateActionSpecificBody(scope, command, flags, body) {
6854
6933
  if (!isRecord2(body) || !command.requestType) {
6855
6934
  return;
6856
6935
  }
6936
+ if (scope === "admin" && command.commandPath[0] === "access-rule" && command.commandPath[1] === "update") {
6937
+ requireUpdateContains(scope, command, body, "enabled");
6938
+ requireUpdateContains(scope, command, body, "name");
6939
+ requireUpdateContains(scope, command, body, "options");
6940
+ }
6941
+ if (isAssignUsersCommand(scope, command)) {
6942
+ const update = stringArray(body.update);
6943
+ const options = isRecord2(body.options) ? body.options : void 0;
6944
+ const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : void 0;
6945
+ if ("users" in body || "allowedUsers" in body) {
6946
+ throwBodyValidationFailure(
6947
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
6948
+ command.requestType,
6949
+ "body.options.user_scope",
6950
+ scope,
6951
+ command
6952
+ );
6953
+ }
6954
+ if (!update.includes("user_scope")) {
6955
+ throwBodyValidationFailure(
6956
+ 'access-rule assign-users requires body.update to include "user_scope"',
6957
+ command.requestType,
6958
+ "body.update",
6959
+ scope,
6960
+ command
6961
+ );
6962
+ }
6963
+ if (!userScope || userScope.length === 0) {
6964
+ throwBodyValidationFailure(
6965
+ "access-rule assign-users requires non-empty body.options.user_scope",
6966
+ command.requestType,
6967
+ "body.options.user_scope",
6968
+ scope,
6969
+ command
6970
+ );
6971
+ }
6972
+ const assignedUserScope = userScope;
6973
+ for (const [index, item] of assignedUserScope.entries()) {
6974
+ if (!isRecord2(item)) {
6975
+ throwBodyValidationFailure(
6976
+ "access-rule assign-users requires each body.options.user_scope item to be an object",
6977
+ command.requestType,
6978
+ `body.options.user_scope.${index}`,
6979
+ scope,
6980
+ command
6981
+ );
6982
+ }
6983
+ if (item.type !== "member") {
6984
+ throwBodyValidationFailure(
6985
+ 'access-rule assign-users requires body.options.user_scope[].type to be "member"',
6986
+ command.requestType,
6987
+ `body.options.user_scope.${index}.type`,
6988
+ scope,
6989
+ command
6990
+ );
6991
+ }
6992
+ if (typeof item.id !== "number" || !Number.isInteger(item.id) || item.id <= 0) {
6993
+ throwBodyValidationFailure(
6994
+ "access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID",
6995
+ command.requestType,
6996
+ `body.options.user_scope.${index}.id`,
6997
+ scope,
6998
+ command
6999
+ );
7000
+ }
7001
+ }
7002
+ }
6857
7003
  if (command.commandPath[0] === "row" && command.commandPath[1] === "selection-action") {
6858
7004
  const action = flagValue(flags, "action");
6859
7005
  const selection = body.selection;
@@ -6954,13 +7100,30 @@ async function executeCLI(scope, argv, env, fetchImpl = fetch) {
6954
7100
  if (body === void 0) {
6955
7101
  throw new CLIError("MISSING_BODY_FILE", `command requires --body-file or --body-json for ${command.requestType}`, 2);
6956
7102
  }
7103
+ validateActionSpecificRawBody(scope, command, body);
6957
7104
  const schema = getBodySchema(command.requestType);
6958
7105
  if (schema) {
6959
7106
  const parsedBody = schema.safeParse(body);
6960
7107
  if (!parsedBody.success) {
7108
+ const message = parsedBody.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
7109
+ if (isAssignUsersCommand(scope, command)) {
7110
+ const hasUserScopeIDIssue = parsedBody.error.issues.some((issue) => issue.path.join(".") === "options.user_scope.0.id" || issue.path.join(".").startsWith("options.user_scope."));
7111
+ throw new CLIError(
7112
+ "BODY_VALIDATION_FAILED",
7113
+ hasUserScopeIDIssue ? "access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID" : message,
7114
+ 2,
7115
+ buildAssignUsersValidationDetails(
7116
+ scope,
7117
+ command,
7118
+ command.requestType,
7119
+ hasUserScopeIDIssue ? "body.options.user_scope" : "body",
7120
+ hasUserScopeIDIssue ? "access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID" : message
7121
+ )
7122
+ );
7123
+ }
6961
7124
  throw new CLIError(
6962
7125
  "BODY_VALIDATION_FAILED",
6963
- parsedBody.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "),
7126
+ message,
6964
7127
  2,
6965
7128
  {
6966
7129
  operation: `${scope}.${command.module}.${command.functionName}`,
@@ -6658,6 +6658,22 @@ function renderCommandHelp(scope, moduleName, commandName) {
6658
6658
  " - row create: arcubase row create --app-id <app_id> --table-id <table_id> --body-file insert.json",
6659
6659
  " - row query: arcubase row query --app-id <app_id> --table-id <table_id> --body-file query.json"
6660
6660
  ] : [],
6661
+ ...scope === "admin" && command.commandPath[0] === "access-rule" && command.commandPath[1] === "update" ? [
6662
+ "body-json examples:",
6663
+ ' - enable: {"update":["enabled"],"enabled":true}',
6664
+ ' - rename: {"update":["name"],"name":"Sales read only"}',
6665
+ ' - update options: {"update":["options"],"options":{"user_scope":[{"type":"member","id":123}]}}'
6666
+ ] : [],
6667
+ ...scope === "admin" && command.commandPath[0] === "access-rule" && command.commandPath[1] === "assign-users" ? [
6668
+ "body-json example:",
6669
+ ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
6670
+ "success requires:",
6671
+ " - result.enabled must be true",
6672
+ " - result.options.user_scope must contain the assigned numeric TenantUser.ID values",
6673
+ " - PermitUserScope.id must be numeric TenantUser.ID",
6674
+ " - Do not copy hash arcubaseTenantUserId into PermitUserScope.id",
6675
+ " - If result.enabled is false, enable the rule first and retry assign-users"
6676
+ ] : [],
6661
6677
  ...docHints.length > 0 ? ["docs:", ...docHints.map((item) => ` - ${item.title}: ${item.file}`)] : []
6662
6678
  ].join("\n");
6663
6679
  }
@@ -6836,7 +6852,38 @@ async function validateFileFieldPayloads(scope, command, runtimeEnv, flags, body
6836
6852
  }
6837
6853
  }
6838
6854
  }
6855
+ function isAssignUsersCommand(scope, command) {
6856
+ return scope === "admin" && command.commandPath[0] === "access-rule" && command.commandPath[1] === "assign-users";
6857
+ }
6858
+ function buildAssignUsersValidationDetails(scope, command, requestType, path2, message) {
6859
+ return {
6860
+ operation: `${scope}.${command.module}.${command.functionName}`,
6861
+ requestType,
6862
+ issues: [{ path: path2, message }],
6863
+ tsHints: [
6864
+ { symbol: "AppIngressUpdateReqVO", file: "/opt/arcubase-sdk/types/ingress.ts" },
6865
+ { symbol: "EntityIngressOptions", file: "/opt/arcubase-sdk/types/ingress.ts" },
6866
+ { symbol: "PermitUserScope", file: "/opt/arcubase-sdk/types/common.ts" }
6867
+ ],
6868
+ docHints: getDocHints(requestType),
6869
+ shapeHint: {
6870
+ update: ["user_scope"],
6871
+ options: {
6872
+ user_scope: [{ type: "member", id: 123 }]
6873
+ }
6874
+ },
6875
+ commonMistakes: [
6876
+ "do not use user_scope at top level",
6877
+ "do not use users or allowedUsers",
6878
+ "do not use hash tenant user id as id",
6879
+ "id must be numeric TenantUser.ID"
6880
+ ]
6881
+ };
6882
+ }
6839
6883
  function throwBodyValidationFailure(message, requestType, path2, scope, command) {
6884
+ if (isAssignUsersCommand(scope, command)) {
6885
+ throw new CLIError("BODY_VALIDATION_FAILED", message, 2, buildAssignUsersValidationDetails(scope, command, requestType, path2, message));
6886
+ }
6840
6887
  throw new CLIError("BODY_VALIDATION_FAILED", message, 2, {
6841
6888
  operation: `${scope}.${command.module}.${command.functionName}`,
6842
6889
  requestType,
@@ -6850,10 +6897,109 @@ function throwBodyValidationFailure(message, requestType, path2, scope, command)
6850
6897
  docHints: getDocHints(requestType)
6851
6898
  });
6852
6899
  }
6900
+ function stringArray(value) {
6901
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
6902
+ }
6903
+ function requireUpdateContains(scope, command, body, field) {
6904
+ if (!(field in body)) return;
6905
+ const update = stringArray(body.update);
6906
+ if (!update.includes(field)) {
6907
+ throwBodyValidationFailure(
6908
+ `body.update must include "${field}" when body.${field} is present. Example: {"update":["${field}"],"${field}":${field === "enabled" ? "true" : '"value"'}}`,
6909
+ command.requestType ?? "AppIngressUpdateReqVO",
6910
+ "body.update",
6911
+ scope,
6912
+ command
6913
+ );
6914
+ }
6915
+ }
6916
+ function validateActionSpecificRawBody(scope, command, body) {
6917
+ if (!isRecord2(body) || !command.requestType) {
6918
+ return;
6919
+ }
6920
+ if (isAssignUsersCommand(scope, command)) {
6921
+ if ("users" in body || "allowedUsers" in body || "user_scope" in body) {
6922
+ throwBodyValidationFailure(
6923
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
6924
+ command.requestType,
6925
+ "body.options.user_scope",
6926
+ scope,
6927
+ command
6928
+ );
6929
+ }
6930
+ }
6931
+ }
6853
6932
  function validateActionSpecificBody(scope, command, flags, body) {
6854
6933
  if (!isRecord2(body) || !command.requestType) {
6855
6934
  return;
6856
6935
  }
6936
+ if (scope === "admin" && command.commandPath[0] === "access-rule" && command.commandPath[1] === "update") {
6937
+ requireUpdateContains(scope, command, body, "enabled");
6938
+ requireUpdateContains(scope, command, body, "name");
6939
+ requireUpdateContains(scope, command, body, "options");
6940
+ }
6941
+ if (isAssignUsersCommand(scope, command)) {
6942
+ const update = stringArray(body.update);
6943
+ const options = isRecord2(body.options) ? body.options : void 0;
6944
+ const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : void 0;
6945
+ if ("users" in body || "allowedUsers" in body) {
6946
+ throwBodyValidationFailure(
6947
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
6948
+ command.requestType,
6949
+ "body.options.user_scope",
6950
+ scope,
6951
+ command
6952
+ );
6953
+ }
6954
+ if (!update.includes("user_scope")) {
6955
+ throwBodyValidationFailure(
6956
+ 'access-rule assign-users requires body.update to include "user_scope"',
6957
+ command.requestType,
6958
+ "body.update",
6959
+ scope,
6960
+ command
6961
+ );
6962
+ }
6963
+ if (!userScope || userScope.length === 0) {
6964
+ throwBodyValidationFailure(
6965
+ "access-rule assign-users requires non-empty body.options.user_scope",
6966
+ command.requestType,
6967
+ "body.options.user_scope",
6968
+ scope,
6969
+ command
6970
+ );
6971
+ }
6972
+ const assignedUserScope = userScope;
6973
+ for (const [index, item] of assignedUserScope.entries()) {
6974
+ if (!isRecord2(item)) {
6975
+ throwBodyValidationFailure(
6976
+ "access-rule assign-users requires each body.options.user_scope item to be an object",
6977
+ command.requestType,
6978
+ `body.options.user_scope.${index}`,
6979
+ scope,
6980
+ command
6981
+ );
6982
+ }
6983
+ if (item.type !== "member") {
6984
+ throwBodyValidationFailure(
6985
+ 'access-rule assign-users requires body.options.user_scope[].type to be "member"',
6986
+ command.requestType,
6987
+ `body.options.user_scope.${index}.type`,
6988
+ scope,
6989
+ command
6990
+ );
6991
+ }
6992
+ if (typeof item.id !== "number" || !Number.isInteger(item.id) || item.id <= 0) {
6993
+ throwBodyValidationFailure(
6994
+ "access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID",
6995
+ command.requestType,
6996
+ `body.options.user_scope.${index}.id`,
6997
+ scope,
6998
+ command
6999
+ );
7000
+ }
7001
+ }
7002
+ }
6857
7003
  if (command.commandPath[0] === "row" && command.commandPath[1] === "selection-action") {
6858
7004
  const action = flagValue(flags, "action");
6859
7005
  const selection = body.selection;
@@ -6954,13 +7100,30 @@ async function executeCLI(scope, argv, env, fetchImpl = fetch) {
6954
7100
  if (body === void 0) {
6955
7101
  throw new CLIError("MISSING_BODY_FILE", `command requires --body-file or --body-json for ${command.requestType}`, 2);
6956
7102
  }
7103
+ validateActionSpecificRawBody(scope, command, body);
6957
7104
  const schema = getBodySchema(command.requestType);
6958
7105
  if (schema) {
6959
7106
  const parsedBody = schema.safeParse(body);
6960
7107
  if (!parsedBody.success) {
7108
+ const message = parsedBody.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ");
7109
+ if (isAssignUsersCommand(scope, command)) {
7110
+ const hasUserScopeIDIssue = parsedBody.error.issues.some((issue) => issue.path.join(".") === "options.user_scope.0.id" || issue.path.join(".").startsWith("options.user_scope."));
7111
+ throw new CLIError(
7112
+ "BODY_VALIDATION_FAILED",
7113
+ hasUserScopeIDIssue ? "access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID" : message,
7114
+ 2,
7115
+ buildAssignUsersValidationDetails(
7116
+ scope,
7117
+ command,
7118
+ command.requestType,
7119
+ hasUserScopeIDIssue ? "body.options.user_scope" : "body",
7120
+ hasUserScopeIDIssue ? "access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID" : message
7121
+ )
7122
+ );
7123
+ }
6961
7124
  throw new CLIError(
6962
7125
  "BODY_VALIDATION_FAILED",
6963
- parsedBody.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "),
7126
+ message,
6964
7127
  2,
6965
7128
  {
6966
7129
  operation: `${scope}.${command.module}.${command.functionName}`,
@@ -16,6 +16,8 @@ export type CLIErrorDetails = {
16
16
  issues?: CLIValidationIssue[];
17
17
  tsHints?: CLITSTypeHint[];
18
18
  docHints?: CLIDocHint[];
19
+ shapeHint?: unknown;
20
+ commonMistakes?: string[];
19
21
  reason?: string;
20
22
  hint?: string;
21
23
  endpoint?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/runtime/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,kBAAkB,EAAE,CAAA;IAC7B,OAAO,CAAC,EAAE,aAAa,EAAE,CAAA;IACzB,QAAQ,CAAC,EAAE,UAAU,EAAE,CAAA;IACvB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CACvB,CAAA;AAED,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAA;gBAEtB,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,SAAI,EAAE,OAAO,CAAC,EAAE,eAAe;IAQlF,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CASlC"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/runtime/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,kBAAkB,EAAE,CAAA;IAC7B,OAAO,CAAC,EAAE,aAAa,EAAE,CAAA;IACzB,QAAQ,CAAC,EAAE,UAAU,EAAE,CAAA;IACvB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAA;IACzB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CACvB,CAAA;AAED,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAA;gBAEtB,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,SAAI,EAAE,OAAO,CAAC,EAAE,eAAe;IAQlF,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CASlC"}
@@ -1 +1 @@
1
- {"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/runtime/execute.ts"],"names":[],"mappings":"AACA,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,UAAU,CAAA;AAG1D,OAAO,EAAwE,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAK/H,MAAM,MAAM,uBAAuB,GAAG;IACpC,KAAK,EAAE,YAAY,CAAA;IACnB,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;IAC5B,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;CAC9B,CAAA;AAYD,wBAAgB,cAAc,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAa1D;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAkBhF;AAOD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CA8BvG;AAoSD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,uBAAuB,CActH;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,CAAC,EAAE,UAAU,EAAE,SAAS,GAAE,OAAO,KAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAiKrI"}
1
+ {"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/runtime/execute.ts"],"names":[],"mappings":"AACA,OAAO,EAAkB,KAAK,UAAU,EAAE,MAAM,UAAU,CAAA;AAG1D,OAAO,EAAwE,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAA;AAK/H,MAAM,MAAM,uBAAuB,GAAG;IACpC,KAAK,EAAE,YAAY,CAAA;IACnB,WAAW,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;IAC5B,UAAU,EAAE,SAAS,MAAM,EAAE,CAAA;CAC9B,CAAA;AAYD,wBAAgB,cAAc,CAAC,KAAK,EAAE,YAAY,GAAG,MAAM,CAa1D;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAkBhF;AAOD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,CAkDvG;AA6bD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,uBAAuB,CActH;AAED,wBAAsB,UAAU,CAAC,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,CAAC,EAAE,UAAU,EAAE,SAAS,GAAE,OAAO,KAAa,GAAG,OAAO,CAAC,GAAG,CAAC,CAkLrI"}
@@ -72,6 +72,26 @@ export function renderCommandHelp(scope, moduleName, commandName) {
72
72
  ' - row query: arcubase row query --app-id <app_id> --table-id <table_id> --body-file query.json',
73
73
  ]
74
74
  : []),
75
+ ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update'
76
+ ? [
77
+ 'body-json examples:',
78
+ ' - enable: {"update":["enabled"],"enabled":true}',
79
+ ' - rename: {"update":["name"],"name":"Sales read only"}',
80
+ ' - update options: {"update":["options"],"options":{"user_scope":[{"type":"member","id":123}]}}',
81
+ ]
82
+ : []),
83
+ ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
84
+ ? [
85
+ 'body-json example:',
86
+ ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
87
+ 'success requires:',
88
+ ' - result.enabled must be true',
89
+ ' - result.options.user_scope must contain the assigned numeric TenantUser.ID values',
90
+ ' - PermitUserScope.id must be numeric TenantUser.ID',
91
+ ' - Do not copy hash arcubaseTenantUserId into PermitUserScope.id',
92
+ ' - If result.enabled is false, enable the rule first and retry assign-users',
93
+ ]
94
+ : []),
75
95
  ...(docHints.length > 0
76
96
  ? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
77
97
  : []),
@@ -269,7 +289,38 @@ async function validateFileFieldPayloads(scope, command, runtimeEnv, flags, body
269
289
  }
270
290
  }
271
291
  }
292
+ function isAssignUsersCommand(scope, command) {
293
+ return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users';
294
+ }
295
+ function buildAssignUsersValidationDetails(scope, command, requestType, path, message) {
296
+ return {
297
+ operation: `${scope}.${command.module}.${command.functionName}`,
298
+ requestType,
299
+ issues: [{ path, message }],
300
+ tsHints: [
301
+ { symbol: 'AppIngressUpdateReqVO', file: '/opt/arcubase-sdk/types/ingress.ts' },
302
+ { symbol: 'EntityIngressOptions', file: '/opt/arcubase-sdk/types/ingress.ts' },
303
+ { symbol: 'PermitUserScope', file: '/opt/arcubase-sdk/types/common.ts' },
304
+ ],
305
+ docHints: getDocHints(requestType),
306
+ shapeHint: {
307
+ update: ['user_scope'],
308
+ options: {
309
+ user_scope: [{ type: 'member', id: 123 }],
310
+ },
311
+ },
312
+ commonMistakes: [
313
+ 'do not use user_scope at top level',
314
+ 'do not use users or allowedUsers',
315
+ 'do not use hash tenant user id as id',
316
+ 'id must be numeric TenantUser.ID',
317
+ ],
318
+ };
319
+ }
272
320
  function throwBodyValidationFailure(message, requestType, path, scope, command) {
321
+ if (isAssignUsersCommand(scope, command)) {
322
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildAssignUsersValidationDetails(scope, command, requestType, path, message));
323
+ }
273
324
  throw new CLIError('BODY_VALIDATION_FAILED', message, 2, {
274
325
  operation: `${scope}.${command.module}.${command.functionName}`,
275
326
  requestType,
@@ -283,10 +334,62 @@ function throwBodyValidationFailure(message, requestType, path, scope, command)
283
334
  docHints: getDocHints(requestType),
284
335
  });
285
336
  }
337
+ function stringArray(value) {
338
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
339
+ }
340
+ function requireUpdateContains(scope, command, body, field) {
341
+ if (!(field in body))
342
+ return;
343
+ const update = stringArray(body.update);
344
+ if (!update.includes(field)) {
345
+ throwBodyValidationFailure(`body.update must include "${field}" when body.${field} is present. Example: {"update":["${field}"],"${field}":${field === 'enabled' ? 'true' : '"value"'}}`, command.requestType ?? 'AppIngressUpdateReqVO', 'body.update', scope, command);
346
+ }
347
+ }
348
+ function validateActionSpecificRawBody(scope, command, body) {
349
+ if (!isRecord(body) || !command.requestType) {
350
+ return;
351
+ }
352
+ if (isAssignUsersCommand(scope, command)) {
353
+ if ('users' in body || 'allowedUsers' in body || 'user_scope' in body) {
354
+ throwBodyValidationFailure('access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}', command.requestType, 'body.options.user_scope', scope, command);
355
+ }
356
+ }
357
+ }
286
358
  function validateActionSpecificBody(scope, command, flags, body) {
287
359
  if (!isRecord(body) || !command.requestType) {
288
360
  return;
289
361
  }
362
+ if (scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update') {
363
+ requireUpdateContains(scope, command, body, 'enabled');
364
+ requireUpdateContains(scope, command, body, 'name');
365
+ requireUpdateContains(scope, command, body, 'options');
366
+ }
367
+ if (isAssignUsersCommand(scope, command)) {
368
+ const update = stringArray(body.update);
369
+ const options = isRecord(body.options) ? body.options : undefined;
370
+ const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : undefined;
371
+ if ('users' in body || 'allowedUsers' in body) {
372
+ throwBodyValidationFailure('access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}', command.requestType, 'body.options.user_scope', scope, command);
373
+ }
374
+ if (!update.includes('user_scope')) {
375
+ throwBodyValidationFailure('access-rule assign-users requires body.update to include "user_scope"', command.requestType, 'body.update', scope, command);
376
+ }
377
+ if (!userScope || userScope.length === 0) {
378
+ throwBodyValidationFailure('access-rule assign-users requires non-empty body.options.user_scope', command.requestType, 'body.options.user_scope', scope, command);
379
+ }
380
+ const assignedUserScope = userScope;
381
+ for (const [index, item] of assignedUserScope.entries()) {
382
+ if (!isRecord(item)) {
383
+ throwBodyValidationFailure('access-rule assign-users requires each body.options.user_scope item to be an object', command.requestType, `body.options.user_scope.${index}`, scope, command);
384
+ }
385
+ if (item.type !== 'member') {
386
+ throwBodyValidationFailure('access-rule assign-users requires body.options.user_scope[].type to be "member"', command.requestType, `body.options.user_scope.${index}.type`, scope, command);
387
+ }
388
+ if (typeof item.id !== 'number' || !Number.isInteger(item.id) || item.id <= 0) {
389
+ throwBodyValidationFailure('access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID', command.requestType, `body.options.user_scope.${index}.id`, scope, command);
390
+ }
391
+ }
392
+ }
290
393
  if (command.commandPath[0] === 'row' && command.commandPath[1] === 'selection-action') {
291
394
  const action = flagValue(flags, 'action');
292
395
  const selection = body.selection;
@@ -379,11 +482,17 @@ export async function executeCLI(scope, argv, env, fetchImpl = fetch) {
379
482
  if (body === undefined) {
380
483
  throw new CLIError('MISSING_BODY_FILE', `command requires --body-file or --body-json for ${command.requestType}`, 2);
381
484
  }
485
+ validateActionSpecificRawBody(scope, command, body);
382
486
  const schema = getBodySchema(command.requestType);
383
487
  if (schema) {
384
488
  const parsedBody = schema.safeParse(body);
385
489
  if (!parsedBody.success) {
386
- throw new CLIError('BODY_VALIDATION_FAILED', parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '), 2, {
490
+ const message = parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
491
+ if (isAssignUsersCommand(scope, command)) {
492
+ const hasUserScopeIDIssue = parsedBody.error.issues.some((issue) => issue.path.join('.') === 'options.user_scope.0.id' || issue.path.join('.').startsWith('options.user_scope.'));
493
+ throw new CLIError('BODY_VALIDATION_FAILED', hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message, 2, buildAssignUsersValidationDetails(scope, command, command.requestType, hasUserScopeIDIssue ? 'body.options.user_scope' : 'body', hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message));
494
+ }
495
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, {
387
496
  operation: `${scope}.${command.module}.${command.functionName}`,
388
497
  requestType: command.requestType,
389
498
  issues: parsedBody.error.issues.map((issue) => ({
@@ -19,6 +19,30 @@ function tempJSONFile(payload) {
19
19
  fs.writeFileSync(filePath, JSON.stringify(payload, null, 2));
20
20
  return filePath;
21
21
  }
22
+ function assertAssignUsersGuidance(error, messagePattern) {
23
+ assert.ok(error instanceof CLIError);
24
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
25
+ if (messagePattern) {
26
+ assert.match(error.message, messagePattern);
27
+ }
28
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO');
29
+ assert.deepEqual(error.details?.shapeHint, {
30
+ update: ['user_scope'],
31
+ options: {
32
+ user_scope: [{ type: 'member', id: 123 }],
33
+ },
34
+ });
35
+ assert.deepEqual(error.details?.commonMistakes, [
36
+ 'do not use user_scope at top level',
37
+ 'do not use users or allowedUsers',
38
+ 'do not use hash tenant user id as id',
39
+ 'id must be numeric TenantUser.ID',
40
+ ]);
41
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/opt/arcubase-sdk/types/ingress.ts'));
42
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/opt/arcubase-sdk/types/ingress.ts'));
43
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/opt/arcubase-sdk/types/common.ts'));
44
+ return true;
45
+ }
22
46
  function validEntitySavePayload(overrides = {}) {
23
47
  return {
24
48
  id: 2188889845,
@@ -370,3 +394,56 @@ test('entry passes include_app_tables query flag', async () => {
370
394
  assert.equal(out.kind, 'result');
371
395
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true');
372
396
  });
397
+ test('access-rule update rejects enabled without update whitelist', async () => {
398
+ await assert.rejects(async () => {
399
+ await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"enabled":true}'], env, async () => new Response('{}', { status: 200 }));
400
+ }, (error) => {
401
+ assert.ok(error instanceof CLIError);
402
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED');
403
+ assert.match(error.message, /update.*enabled/);
404
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO');
405
+ assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/opt/arcubase-sdk/types/ingress.ts'));
406
+ return true;
407
+ });
408
+ });
409
+ test('access-rule update accepts enabled with update whitelist', async () => {
410
+ const out = await executeCLI('admin', ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["enabled"],"enabled":true}'], env, async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
411
+ assert.equal(out.kind, 'result');
412
+ assert.deepEqual(out.data, { update: ['enabled'], enabled: true });
413
+ });
414
+ test('access-rule assign-users rejects users shortcut', async () => {
415
+ await assert.rejects(async () => {
416
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"users":["tu_1"]}'], env, async () => new Response('{}', { status: 200 }));
417
+ }, (error) => assertAssignUsersGuidance(error, /options.user_scope/));
418
+ });
419
+ test('access-rule assign-users requires update user_scope and non-empty user_scope', async () => {
420
+ await assert.rejects(async () => {
421
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[]}}'], env, async () => new Response('{}', { status: 200 }));
422
+ }, (error) => assertAssignUsersGuidance(error, /non-empty/));
423
+ });
424
+ test('access-rule assign-users rejects allowedUsers shortcut', async () => {
425
+ await assert.rejects(async () => {
426
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"allowedUsers":["tu_1"]}'], env, async () => new Response('{}', { status: 200 }));
427
+ }, (error) => assertAssignUsersGuidance(error, /options.user_scope/));
428
+ });
429
+ test('access-rule assign-users rejects top-level user_scope', async () => {
430
+ await assert.rejects(async () => {
431
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"member","id":123}]}'], env, async () => new Response('{}', { status: 200 }));
432
+ }, (error) => assertAssignUsersGuidance(error, /options.user_scope/));
433
+ });
434
+ test('access-rule assign-users rejects non-member scope type', async () => {
435
+ await assert.rejects(async () => {
436
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}'], env, async () => new Response('{}', { status: 200 }));
437
+ }, (error) => assertAssignUsersGuidance(error, /type.*member/));
438
+ });
439
+ test('access-rule assign-users rejects hash tenant user id', async () => {
440
+ await assert.rejects(async () => {
441
+ await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":"zW96OnE0kyJa3B2"}]}}'], env, async () => new Response('{}', { status: 200 }));
442
+ }, (error) => assertAssignUsersGuidance(error, /numeric TenantUser.ID/));
443
+ });
444
+ test('access-rule assign-users accepts canonical user_scope body', async () => {
445
+ const out = await executeCLI('admin', ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}'], env, async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }));
446
+ assert.equal(out.kind, 'result');
447
+ assert.deepEqual(out.data.update, ['user_scope']);
448
+ assert.deepEqual(out.data.options.user_scope, [{ type: 'member', id: 123 }]);
449
+ });
@@ -58,3 +58,18 @@ test('admin table update-schema help uses table nouns and body-json', async () =
58
58
  assert.doesNotMatch(out.text, /--entity-id/);
59
59
  assert.doesNotMatch(out.text, /admin-save-entity/);
60
60
  });
61
+ test('admin access-rule help gives update whitelist and assign-users body-json examples', async () => {
62
+ const update = await executeCLI('admin', ['access-rule', 'update', '--help'], undefined, async () => new Response('{}'));
63
+ assert.equal(update.kind, 'help');
64
+ assert.match(update.text, /--app-id/);
65
+ assert.match(update.text, /--table-id/);
66
+ assert.match(update.text, /--rule-id/);
67
+ assert.match(update.text, /"update":\["enabled"\]/);
68
+ const assign = await executeCLI('admin', ['access-rule', 'assign-users', '--help'], undefined, async () => new Response('{}'));
69
+ assert.equal(assign.kind, 'help');
70
+ assert.match(assign.text, /"update":\["user_scope"\]/);
71
+ assert.match(assign.text, /\{"update":\["user_scope"\],"options":\{"user_scope":\[\{"type":"member","id":123\}\]\}\}/);
72
+ assert.doesNotMatch(assign.text, /"type":"user"/);
73
+ assert.doesNotMatch(assign.text, /"users"/);
74
+ assert.doesNotMatch(assign.text, /"allowedUsers"/);
75
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carthooks/arcubase-cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Arcubase runtime CLI for admin and user command execution",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,6 +19,8 @@ export type CLIErrorDetails = {
19
19
  issues?: CLIValidationIssue[]
20
20
  tsHints?: CLITSTypeHint[]
21
21
  docHints?: CLIDocHint[]
22
+ shapeHint?: unknown
23
+ commonMistakes?: string[]
22
24
  reason?: string
23
25
  hint?: string
24
26
  endpoint?: string
@@ -1,6 +1,6 @@
1
1
  import { parseCLIArgs, hasFlag, flagValue } from './argv.js'
2
2
  import { loadRuntimeEnv, type RuntimeEnv } from './env.js'
3
- import { CLIError } from './errors.js'
3
+ import { CLIError, type CLIErrorDetails } from './errors.js'
4
4
  import { buildRequestHeaders, buildURL, normalizeExternalEndpoint } from './http.js'
5
5
  import { listModules, listModuleCommands, findCommand, findCommandSuggestions, type CommandScope } from './command_registry.js'
6
6
  import { loadBodyFromFlags, loadQueryFromFlags } from './body_loader.js'
@@ -98,16 +98,19 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
98
98
  'body-json examples:',
99
99
  ' - enable: {"update":["enabled"],"enabled":true}',
100
100
  ' - rename: {"update":["name"],"name":"Sales read only"}',
101
- ' - update options: {"update":["options"],"options":{"user_scope":[{"type":"user","id":123}]}}',
101
+ ' - update options: {"update":["options"],"options":{"user_scope":[{"type":"member","id":123}]}}',
102
102
  ]
103
103
  : []),
104
104
  ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
105
105
  ? [
106
106
  'body-json example:',
107
- ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}',
107
+ ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
108
108
  'success requires:',
109
109
  ' - result.enabled must be true',
110
- ' - result.options.user_scope must contain the assigned user ids',
110
+ ' - result.options.user_scope must contain the assigned numeric TenantUser.ID values',
111
+ ' - PermitUserScope.id must be numeric TenantUser.ID',
112
+ ' - Do not copy hash arcubaseTenantUserId into PermitUserScope.id',
113
+ ' - If result.enabled is false, enable the rule first and retry assign-users',
111
114
  ]
112
115
  : []),
113
116
  ...(docHints.length > 0
@@ -335,7 +338,46 @@ async function validateFileFieldPayloads(
335
338
  }
336
339
  }
337
340
 
338
- function throwBodyValidationFailure(message: string, requestType: string, path: string, scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>) {
341
+ function isAssignUsersCommand(scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): boolean {
342
+ return scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
343
+ }
344
+
345
+ function buildAssignUsersValidationDetails(
346
+ scope: CommandScope,
347
+ command: NonNullable<ReturnType<typeof findCommand>>,
348
+ requestType: string,
349
+ path: string,
350
+ message: string,
351
+ ): CLIErrorDetails {
352
+ return {
353
+ operation: `${scope}.${command.module}.${command.functionName}`,
354
+ requestType,
355
+ issues: [{ path, message }],
356
+ tsHints: [
357
+ { symbol: 'AppIngressUpdateReqVO', file: '/opt/arcubase-sdk/types/ingress.ts' },
358
+ { symbol: 'EntityIngressOptions', file: '/opt/arcubase-sdk/types/ingress.ts' },
359
+ { symbol: 'PermitUserScope', file: '/opt/arcubase-sdk/types/common.ts' },
360
+ ],
361
+ docHints: getDocHints(requestType),
362
+ shapeHint: {
363
+ update: ['user_scope'],
364
+ options: {
365
+ user_scope: [{ type: 'member', id: 123 }],
366
+ },
367
+ },
368
+ commonMistakes: [
369
+ 'do not use user_scope at top level',
370
+ 'do not use users or allowedUsers',
371
+ 'do not use hash tenant user id as id',
372
+ 'id must be numeric TenantUser.ID',
373
+ ],
374
+ }
375
+ }
376
+
377
+ function throwBodyValidationFailure(message: string, requestType: string, path: string, scope: CommandScope, command: NonNullable<ReturnType<typeof findCommand>>): never {
378
+ if (isAssignUsersCommand(scope, command)) {
379
+ throw new CLIError('BODY_VALIDATION_FAILED', message, 2, buildAssignUsersValidationDetails(scope, command, requestType, path, message))
380
+ }
339
381
  throw new CLIError('BODY_VALIDATION_FAILED', message, 2, {
340
382
  operation: `${scope}.${command.module}.${command.functionName}`,
341
383
  requestType,
@@ -382,10 +424,10 @@ function validateActionSpecificRawBody(
382
424
  return
383
425
  }
384
426
 
385
- if (scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users') {
386
- if ('users' in body || 'allowedUsers' in body) {
427
+ if (isAssignUsersCommand(scope, command)) {
428
+ if ('users' in body || 'allowedUsers' in body || 'user_scope' in body) {
387
429
  throwBodyValidationFailure(
388
- 'access-rule assign-users does not accept users or allowedUsers; use body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}',
430
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
389
431
  command.requestType,
390
432
  'body.options.user_scope',
391
433
  scope,
@@ -411,13 +453,13 @@ function validateActionSpecificBody(
411
453
  requireUpdateContains(scope, command, body, 'options')
412
454
  }
413
455
 
414
- if (scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users') {
456
+ if (isAssignUsersCommand(scope, command)) {
415
457
  const update = stringArray(body.update)
416
458
  const options = isRecord(body.options) ? body.options : undefined
417
459
  const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : undefined
418
460
  if ('users' in body || 'allowedUsers' in body) {
419
461
  throwBodyValidationFailure(
420
- 'access-rule assign-users does not accept users or allowedUsers; use body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}',
462
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
421
463
  command.requestType,
422
464
  'body.options.user_scope',
423
465
  scope,
@@ -442,6 +484,36 @@ function validateActionSpecificBody(
442
484
  command,
443
485
  )
444
486
  }
487
+ const assignedUserScope = userScope
488
+ for (const [index, item] of assignedUserScope.entries()) {
489
+ if (!isRecord(item)) {
490
+ throwBodyValidationFailure(
491
+ 'access-rule assign-users requires each body.options.user_scope item to be an object',
492
+ command.requestType,
493
+ `body.options.user_scope.${index}`,
494
+ scope,
495
+ command,
496
+ )
497
+ }
498
+ if (item.type !== 'member') {
499
+ throwBodyValidationFailure(
500
+ 'access-rule assign-users requires body.options.user_scope[].type to be "member"',
501
+ command.requestType,
502
+ `body.options.user_scope.${index}.type`,
503
+ scope,
504
+ command,
505
+ )
506
+ }
507
+ if (typeof item.id !== 'number' || !Number.isInteger(item.id) || item.id <= 0) {
508
+ throwBodyValidationFailure(
509
+ 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID',
510
+ command.requestType,
511
+ `body.options.user_scope.${index}.id`,
512
+ scope,
513
+ command,
514
+ )
515
+ }
516
+ }
445
517
  }
446
518
 
447
519
  if (command.commandPath[0] === 'row' && command.commandPath[1] === 'selection-action') {
@@ -575,9 +647,25 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
575
647
  if (schema) {
576
648
  const parsedBody = schema.safeParse(body)
577
649
  if (!parsedBody.success) {
650
+ const message = parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ')
651
+ if (isAssignUsersCommand(scope, command)) {
652
+ const hasUserScopeIDIssue = parsedBody.error.issues.some((issue) => issue.path.join('.') === 'options.user_scope.0.id' || issue.path.join('.').startsWith('options.user_scope.'))
653
+ throw new CLIError(
654
+ 'BODY_VALIDATION_FAILED',
655
+ hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message,
656
+ 2,
657
+ buildAssignUsersValidationDetails(
658
+ scope,
659
+ command,
660
+ command.requestType,
661
+ hasUserScopeIDIssue ? 'body.options.user_scope' : 'body',
662
+ hasUserScopeIDIssue ? 'access-rule assign-users requires body.options.user_scope[].id to be numeric TenantUser.ID' : message,
663
+ )
664
+ )
665
+ }
578
666
  throw new CLIError(
579
667
  'BODY_VALIDATION_FAILED',
580
- parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '),
668
+ message,
581
669
  2,
582
670
  {
583
671
  operation: `${scope}.${command.module}.${command.functionName}`,
@@ -22,6 +22,31 @@ function tempJSONFile(payload: unknown): string {
22
22
  return filePath
23
23
  }
24
24
 
25
+ function assertAssignUsersGuidance(error: unknown, messagePattern?: RegExp): boolean {
26
+ assert.ok(error instanceof CLIError)
27
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
28
+ if (messagePattern) {
29
+ assert.match(error.message, messagePattern)
30
+ }
31
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
32
+ assert.deepEqual(error.details?.shapeHint, {
33
+ update: ['user_scope'],
34
+ options: {
35
+ user_scope: [{ type: 'member', id: 123 }],
36
+ },
37
+ })
38
+ assert.deepEqual(error.details?.commonMistakes, [
39
+ 'do not use user_scope at top level',
40
+ 'do not use users or allowedUsers',
41
+ 'do not use hash tenant user id as id',
42
+ 'id must be numeric TenantUser.ID',
43
+ ])
44
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'AppIngressUpdateReqVO' && item.file === '/opt/arcubase-sdk/types/ingress.ts'))
45
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'EntityIngressOptions' && item.file === '/opt/arcubase-sdk/types/ingress.ts'))
46
+ assert.ok(error.details?.tsHints?.some((item) => item.symbol === 'PermitUserScope' && item.file === '/opt/arcubase-sdk/types/common.ts'))
47
+ return true
48
+ }
49
+
25
50
  function validEntitySavePayload(overrides: Record<string, unknown> = {}) {
26
51
  return {
27
52
  id: 2188889845,
@@ -501,13 +526,7 @@ test('access-rule assign-users rejects users shortcut', async () => {
501
526
  env as any,
502
527
  async () => new Response('{}', { status: 200 }),
503
528
  )
504
- }, (error: unknown) => {
505
- assert.ok(error instanceof CLIError)
506
- assert.equal(error.code, 'BODY_VALIDATION_FAILED')
507
- assert.match(error.message, /options.user_scope/)
508
- assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
509
- return true
510
- })
529
+ }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
511
530
  })
512
531
 
513
532
  test('access-rule assign-users requires update user_scope and non-empty user_scope', async () => {
@@ -518,22 +537,61 @@ test('access-rule assign-users requires update user_scope and non-empty user_sco
518
537
  env as any,
519
538
  async () => new Response('{}', { status: 200 }),
520
539
  )
521
- }, (error: unknown) => {
522
- assert.ok(error instanceof CLIError)
523
- assert.equal(error.code, 'BODY_VALIDATION_FAILED')
524
- assert.match(error.message, /non-empty/)
525
- return true
526
- })
540
+ }, (error: unknown) => assertAssignUsersGuidance(error, /non-empty/))
541
+ })
542
+
543
+ test('access-rule assign-users rejects allowedUsers shortcut', async () => {
544
+ await assert.rejects(async () => {
545
+ await executeCLI(
546
+ 'admin',
547
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"allowedUsers":["tu_1"]}'],
548
+ env as any,
549
+ async () => new Response('{}', { status: 200 }),
550
+ )
551
+ }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
552
+ })
553
+
554
+ test('access-rule assign-users rejects top-level user_scope', async () => {
555
+ await assert.rejects(async () => {
556
+ await executeCLI(
557
+ 'admin',
558
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"user_scope":[{"type":"member","id":123}]}'],
559
+ env as any,
560
+ async () => new Response('{}', { status: 200 }),
561
+ )
562
+ }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
563
+ })
564
+
565
+ test('access-rule assign-users rejects non-member scope type', async () => {
566
+ await assert.rejects(async () => {
567
+ await executeCLI(
568
+ 'admin',
569
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}'],
570
+ env as any,
571
+ async () => new Response('{}', { status: 200 }),
572
+ )
573
+ }, (error: unknown) => assertAssignUsersGuidance(error, /type.*member/))
574
+ })
575
+
576
+ test('access-rule assign-users rejects hash tenant user id', async () => {
577
+ await assert.rejects(async () => {
578
+ await executeCLI(
579
+ 'admin',
580
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":"zW96OnE0kyJa3B2"}]}}'],
581
+ env as any,
582
+ async () => new Response('{}', { status: 200 }),
583
+ )
584
+ }, (error: unknown) => assertAssignUsersGuidance(error, /numeric TenantUser.ID/))
527
585
  })
528
586
 
529
587
  test('access-rule assign-users accepts canonical user_scope body', async () => {
530
588
  const out = await executeCLI(
531
589
  'admin',
532
- ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"user","id":123}]}}'],
590
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}'],
533
591
  env as any,
534
592
  async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
535
593
  )
536
594
  assert.equal(out.kind, 'result')
537
595
  assert.deepEqual(out.data.update, ['user_scope'])
538
- assert.deepEqual(out.data.options.user_scope, [{ type: 'user', id: 123 }])
596
+ assert.deepEqual(out.data.options.user_scope, [{ type: 'member', id: 123 }])
539
597
  })
@@ -76,5 +76,8 @@ test('admin access-rule help gives update whitelist and assign-users body-json e
76
76
  const assign = await executeCLI('admin', ['access-rule', 'assign-users', '--help'], undefined, async () => new Response('{}'))
77
77
  assert.equal(assign.kind, 'help')
78
78
  assert.match(assign.text, /"update":\["user_scope"\]/)
79
- assert.match(assign.text, /"options":\{"user_scope":\[/)
79
+ assert.match(assign.text, /\{"update":\["user_scope"\],"options":\{"user_scope":\[\{"type":"member","id":123\}\]\}\}/)
80
+ assert.doesNotMatch(assign.text, /"type":"user"/)
81
+ assert.doesNotMatch(assign.text, /"users"/)
82
+ assert.doesNotMatch(assign.text, /"allowedUsers"/)
80
83
  })