@carthooks/arcubase-cli 0.1.8 → 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.8",
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'
@@ -93,6 +93,26 @@ export function renderCommandHelp(scope: CommandScope, moduleName: string, comma
93
93
  ' - row query: arcubase row query --app-id <app_id> --table-id <table_id> --body-file query.json',
94
94
  ]
95
95
  : []),
96
+ ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update'
97
+ ? [
98
+ 'body-json examples:',
99
+ ' - enable: {"update":["enabled"],"enabled":true}',
100
+ ' - rename: {"update":["name"],"name":"Sales read only"}',
101
+ ' - update options: {"update":["options"],"options":{"user_scope":[{"type":"member","id":123}]}}',
102
+ ]
103
+ : []),
104
+ ...(scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'assign-users'
105
+ ? [
106
+ 'body-json example:',
107
+ ' - {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
108
+ 'success requires:',
109
+ ' - result.enabled must be true',
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',
114
+ ]
115
+ : []),
96
116
  ...(docHints.length > 0
97
117
  ? ['docs:', ...docHints.map((item) => ` - ${item.title}: ${item.file}`)]
98
118
  : []),
@@ -318,7 +338,46 @@ async function validateFileFieldPayloads(
318
338
  }
319
339
  }
320
340
 
321
- 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
+ }
322
381
  throw new CLIError('BODY_VALIDATION_FAILED', message, 2, {
323
382
  operation: `${scope}.${command.module}.${command.functionName}`,
324
383
  requestType,
@@ -333,6 +392,51 @@ function throwBodyValidationFailure(message: string, requestType: string, path:
333
392
  })
334
393
  }
335
394
 
395
+ function stringArray(value: unknown): string[] {
396
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
397
+ }
398
+
399
+ function requireUpdateContains(
400
+ scope: CommandScope,
401
+ command: NonNullable<ReturnType<typeof findCommand>>,
402
+ body: Record<string, unknown>,
403
+ field: string,
404
+ ) {
405
+ if (!(field in body)) return
406
+ const update = stringArray(body.update)
407
+ if (!update.includes(field)) {
408
+ throwBodyValidationFailure(
409
+ `body.update must include "${field}" when body.${field} is present. Example: {"update":["${field}"],"${field}":${field === 'enabled' ? 'true' : '"value"'}}`,
410
+ command.requestType ?? 'AppIngressUpdateReqVO',
411
+ 'body.update',
412
+ scope,
413
+ command,
414
+ )
415
+ }
416
+ }
417
+
418
+ function validateActionSpecificRawBody(
419
+ scope: CommandScope,
420
+ command: NonNullable<ReturnType<typeof findCommand>>,
421
+ body: unknown,
422
+ ) {
423
+ if (!isRecord(body) || !command.requestType) {
424
+ return
425
+ }
426
+
427
+ if (isAssignUsersCommand(scope, command)) {
428
+ if ('users' in body || 'allowedUsers' in body || 'user_scope' in body) {
429
+ throwBodyValidationFailure(
430
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
431
+ command.requestType,
432
+ 'body.options.user_scope',
433
+ scope,
434
+ command,
435
+ )
436
+ }
437
+ }
438
+ }
439
+
336
440
  function validateActionSpecificBody(
337
441
  scope: CommandScope,
338
442
  command: NonNullable<ReturnType<typeof findCommand>>,
@@ -343,6 +447,75 @@ function validateActionSpecificBody(
343
447
  return
344
448
  }
345
449
 
450
+ if (scope === 'admin' && command.commandPath[0] === 'access-rule' && command.commandPath[1] === 'update') {
451
+ requireUpdateContains(scope, command, body, 'enabled')
452
+ requireUpdateContains(scope, command, body, 'name')
453
+ requireUpdateContains(scope, command, body, 'options')
454
+ }
455
+
456
+ if (isAssignUsersCommand(scope, command)) {
457
+ const update = stringArray(body.update)
458
+ const options = isRecord(body.options) ? body.options : undefined
459
+ const userScope = options && Array.isArray(options.user_scope) ? options.user_scope : undefined
460
+ if ('users' in body || 'allowedUsers' in body) {
461
+ throwBodyValidationFailure(
462
+ 'access-rule assign-users only accepts body.options.user_scope with {"update":["user_scope"],"options":{"user_scope":[{"type":"member","id":123}]}}',
463
+ command.requestType,
464
+ 'body.options.user_scope',
465
+ scope,
466
+ command,
467
+ )
468
+ }
469
+ if (!update.includes('user_scope')) {
470
+ throwBodyValidationFailure(
471
+ 'access-rule assign-users requires body.update to include "user_scope"',
472
+ command.requestType,
473
+ 'body.update',
474
+ scope,
475
+ command,
476
+ )
477
+ }
478
+ if (!userScope || userScope.length === 0) {
479
+ throwBodyValidationFailure(
480
+ 'access-rule assign-users requires non-empty body.options.user_scope',
481
+ command.requestType,
482
+ 'body.options.user_scope',
483
+ scope,
484
+ command,
485
+ )
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
+ }
517
+ }
518
+
346
519
  if (command.commandPath[0] === 'row' && command.commandPath[1] === 'selection-action') {
347
520
  const action = flagValue(flags, 'action')
348
521
  const selection = body.selection
@@ -469,13 +642,30 @@ export async function executeCLI(scope: CommandScope, argv: string[], env?: Runt
469
642
  if (body === undefined) {
470
643
  throw new CLIError('MISSING_BODY_FILE', `command requires --body-file or --body-json for ${command.requestType}`, 2)
471
644
  }
645
+ validateActionSpecificRawBody(scope, command, body)
472
646
  const schema = getBodySchema(command.requestType)
473
647
  if (schema) {
474
648
  const parsedBody = schema.safeParse(body)
475
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
+ }
476
666
  throw new CLIError(
477
667
  'BODY_VALIDATION_FAILED',
478
- parsedBody.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '),
668
+ message,
479
669
  2,
480
670
  {
481
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,
@@ -463,3 +488,110 @@ test('entry passes include_app_tables query flag', async () => {
463
488
  assert.equal(out.kind, 'result')
464
489
  assert.equal(out.data.url, 'https://arcubase.example.com/api/entry?include_app_tables=true')
465
490
  })
491
+
492
+ test('access-rule update rejects enabled without update whitelist', async () => {
493
+ await assert.rejects(async () => {
494
+ await executeCLI(
495
+ 'admin',
496
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"enabled":true}'],
497
+ env as any,
498
+ async () => new Response('{}', { status: 200 }),
499
+ )
500
+ }, (error: unknown) => {
501
+ assert.ok(error instanceof CLIError)
502
+ assert.equal(error.code, 'BODY_VALIDATION_FAILED')
503
+ assert.match(error.message, /update.*enabled/)
504
+ assert.equal(error.details?.requestType, 'AppIngressUpdateReqVO')
505
+ assert.ok((error.details?.tsHints ?? []).some((item) => item.file === '/opt/arcubase-sdk/types/ingress.ts'))
506
+ return true
507
+ })
508
+ })
509
+
510
+ test('access-rule update accepts enabled with update whitelist', async () => {
511
+ const out = await executeCLI(
512
+ 'admin',
513
+ ['access-rule', 'update', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["enabled"],"enabled":true}'],
514
+ env as any,
515
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
516
+ )
517
+ assert.equal(out.kind, 'result')
518
+ assert.deepEqual(out.data, { update: ['enabled'], enabled: true })
519
+ })
520
+
521
+ test('access-rule assign-users rejects users shortcut', async () => {
522
+ await assert.rejects(async () => {
523
+ await executeCLI(
524
+ 'admin',
525
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"users":["tu_1"]}'],
526
+ env as any,
527
+ async () => new Response('{}', { status: 200 }),
528
+ )
529
+ }, (error: unknown) => assertAssignUsersGuidance(error, /options.user_scope/))
530
+ })
531
+
532
+ test('access-rule assign-users requires update user_scope and non-empty user_scope', async () => {
533
+ await assert.rejects(async () => {
534
+ await executeCLI(
535
+ 'admin',
536
+ ['access-rule', 'assign-users', '--app-id', 'app_1', '--table-id', 'table_1', '--rule-id', 'rule_1', '--body-json', '{"update":["user_scope"],"options":{"user_scope":[]}}'],
537
+ env as any,
538
+ async () => new Response('{}', { status: 200 }),
539
+ )
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/))
585
+ })
586
+
587
+ test('access-rule assign-users accepts canonical user_scope body', async () => {
588
+ const out = await executeCLI(
589
+ 'admin',
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}]}}'],
591
+ env as any,
592
+ async (_url, init) => new Response(String(init?.body ?? '{}'), { status: 200 }),
593
+ )
594
+ assert.equal(out.kind, 'result')
595
+ assert.deepEqual(out.data.update, ['user_scope'])
596
+ assert.deepEqual(out.data.options.user_scope, [{ type: 'member', id: 123 }])
597
+ })
@@ -64,3 +64,20 @@ test('admin table update-schema help uses table nouns and body-json', async () =
64
64
  assert.doesNotMatch(out.text, /--entity-id/)
65
65
  assert.doesNotMatch(out.text, /admin-save-entity/)
66
66
  })
67
+
68
+ test('admin access-rule help gives update whitelist and assign-users body-json examples', async () => {
69
+ const update = await executeCLI('admin', ['access-rule', 'update', '--help'], undefined, async () => new Response('{}'))
70
+ assert.equal(update.kind, 'help')
71
+ assert.match(update.text, /--app-id/)
72
+ assert.match(update.text, /--table-id/)
73
+ assert.match(update.text, /--rule-id/)
74
+ assert.match(update.text, /"update":\["enabled"\]/)
75
+
76
+ const assign = await executeCLI('admin', ['access-rule', 'assign-users', '--help'], undefined, async () => new Response('{}'))
77
+ assert.equal(assign.kind, 'help')
78
+ assert.match(assign.text, /"update":\["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"/)
83
+ })